mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf1215bb1 | ||
|
|
1b633720be | ||
|
|
adb5d6e998 | ||
|
|
3841946b61 | ||
|
|
369e2703c2 | ||
|
|
4a45ad91fa | ||
|
|
265dac45ce | ||
|
|
f152787578 | ||
|
|
640623f4fd | ||
|
|
1aff89f377 | ||
|
|
7d6b12a4ef | ||
|
|
7a3223e415 | ||
|
|
bd18688f35 | ||
|
|
f90da96820 | ||
|
|
6c8f2d2cd6 | ||
|
|
5e84305922 | ||
|
|
b3421861e6 | ||
|
|
872492ad22 | ||
|
|
5c14c7cf5e | ||
|
|
b59f0f418e | ||
|
|
06d3e27e03 | ||
|
|
b1093baef7 | ||
|
|
b3f09ff045 | ||
|
|
b33ad36f64 | ||
|
|
9254ce46eb | ||
|
|
cc8c855306 | ||
|
|
b74994b906 | ||
|
|
f652dc71bb | ||
|
|
6d2677a5e3 | ||
|
|
0c5305c174 | ||
|
|
5398576f4f | ||
|
|
3d1f570b38 | ||
|
|
fa057502f1 | ||
|
|
bb4a5cfe83 | ||
|
|
ac1fd74942 | ||
|
|
db660ac780 | ||
|
|
cdeb9a94ed | ||
|
|
6da94aef8d | ||
|
|
0a4fdc17a5 | ||
|
|
1bbba7a0ae | ||
|
|
fcc4e305e0 | ||
|
|
854796a8dd | ||
|
|
e6a41773ca | ||
|
|
f4a6d8af49 | ||
|
|
1563855251 | ||
|
|
1330e2e1e7 | ||
|
|
34fe55686d | ||
|
|
b0aa387235 | ||
|
|
c3f7aeb856 | ||
|
|
54a6ae6710 | ||
|
|
8dfc90985b | ||
|
|
99ea916c14 | ||
|
|
70151a3c19 | ||
|
|
543fb350ec | ||
|
|
ea4e3128ca | ||
|
|
ae8cbc8f45 | ||
|
|
cb156e1903 | ||
|
|
edcb6950e5 | ||
|
|
085fb1601e | ||
|
|
132a8c0aab | ||
|
|
4f3ca6fe2b | ||
|
|
13c0572fe1 | ||
|
|
fda4b67dbc | ||
|
|
aefbccbfea | ||
|
|
d1336da339 | ||
|
|
f453cefc0b | ||
|
|
b6bc09fee1 | ||
|
|
437843084b | ||
|
|
1e4c665b53 | ||
|
|
ac52befb5b | ||
|
|
50d7df45eb | ||
|
|
7e0a4e61b4 | ||
|
|
689ad644c1 | ||
|
|
f660707712 | ||
|
|
06016722d1 | ||
|
|
832d7f360c | ||
|
|
939653ecc0 | ||
|
|
610a948dcc | ||
|
|
b2284b5f4b | ||
|
|
d9e1a759a1 | ||
|
|
624b443f98 | ||
|
|
71a70bac9d | ||
|
|
0110869c89 | ||
|
|
7d4017225c | ||
|
|
94a1cc07d5 | ||
|
|
1720c82570 | ||
|
|
81bd1a1732 | ||
|
|
f421da4b9b | ||
|
|
3eaa3ca1b5 | ||
|
|
2d1ad16b4f | ||
|
|
6179864828 | ||
|
|
64d7ab22f3 | ||
|
|
4962dc618b | ||
|
|
8e2246113a | ||
|
|
b9993aaa73 | ||
|
|
f77fb0cc1c | ||
|
|
460cc35bb6 | ||
|
|
f0bcffec8b | ||
|
|
2b465b0646 | ||
|
|
fdccdcebad | ||
|
|
a38bd5bedc | ||
|
|
6fe04bd280 | ||
|
|
d0a68da7e7 | ||
|
|
ede67dbc20 | ||
|
|
3d3fe5c614 | ||
|
|
7bb33d4453 | ||
|
|
0ad4dbc65a | ||
|
|
c3844250e8 | ||
|
|
c293994d2b | ||
|
|
a557aa35f5 | ||
|
|
736e9673ad | ||
|
|
13d96e793b | ||
|
|
5e37c90dde | ||
|
|
7bcd00a87e | ||
|
|
ebfbb55f91 | ||
|
|
d77479131a | ||
|
|
8ef00efe84 | ||
|
|
a4101f7670 | ||
|
|
08b4fc20a9 | ||
|
|
320f990f84 | ||
|
|
7297f55ca4 | ||
|
|
2cb642bbf7 | ||
|
|
ecdf9c26cd | ||
|
|
a672ab959f | ||
|
|
1571ebe4eb | ||
|
|
b8bb5e8d72 | ||
|
|
af77554250 | ||
|
|
3b68782cfb | ||
|
|
8388e36f28 | ||
|
|
051b3702b0 | ||
|
|
39accdba58 | ||
|
|
32de0aa40a | ||
|
|
9bfcdd086a | ||
|
|
1d20124467 | ||
|
|
435eca6f35 | ||
|
|
0a61db6efd | ||
|
|
7ab8405946 | ||
|
|
6fa3e99be2 | ||
|
|
3160fb2d99 | ||
|
|
1cbf16b3bf | ||
|
|
dad289b90d | ||
|
|
c795ecd21e | ||
|
|
21607f0f66 | ||
|
|
fc311a8d28 | ||
|
|
93606c6647 | ||
|
|
1adcfcc03b | ||
|
|
ed4304dc30 | ||
|
|
c0a6a21b9e | ||
|
|
a7bb599cd0 | ||
|
|
28ba4655c1 | ||
|
|
bd95a5b74c | ||
|
|
745b230097 | ||
|
|
ec303a60ed | ||
|
|
e99b0627d6 | ||
|
|
b77b6d1a18 | ||
|
|
779b23d919 | ||
|
|
a5b27cce5c | ||
|
|
ebd6891471 | ||
|
|
3cf3e04866 | ||
|
|
ff5508cb79 | ||
|
|
f07f7a1e35 | ||
|
|
881b625177 | ||
|
|
4c2dcac61a | ||
|
|
dcb00a3917 | ||
|
|
ddca49ba16 | ||
|
|
0fb92720f8 | ||
|
|
7de346b532 | ||
|
|
729f9f142e | ||
|
|
45b73e3dfb | ||
|
|
d3711b0503 | ||
|
|
9d8df8d05d | ||
|
|
97f29e4305 | ||
|
|
fcfcaa0628 | ||
|
|
d5314b028b | ||
|
|
3be5875ea9 | ||
|
|
94680c41f7 | ||
|
|
af71b63f23 | ||
|
|
e40cf46b33 | ||
|
|
5b330ab5b4 | ||
|
|
7dcfa65146 | ||
|
|
a6bb1bf096 | ||
|
|
728427cecf | ||
|
|
651b439096 | ||
|
|
7a41ff0127 |
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.20.3'
|
||||
go-version: '>=1.21.0'
|
||||
|
||||
# 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.
|
||||
@@ -29,6 +29,13 @@ jobs:
|
||||
- name: Build Admin dashboard UI
|
||||
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
||||
|
||||
# Temporary disable as the types can have random generated identifiers making it non-deterministic.
|
||||
#
|
||||
# # Similar to the above, the jsvm types are pregenerated locally
|
||||
# # but its here to ensure that it wasn't forgotten to be executed.
|
||||
# - name: Generate jsvm types
|
||||
# run: go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
||||
# https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - name: Run linter
|
||||
|
||||
+202
-14
@@ -1,6 +1,194 @@
|
||||
## v0.17.4
|
||||
|
||||
- Fixed Views record retrieval when numeric id is used ([#3110](https://github.com/pocketbase/pocketbase/issues/3110)).
|
||||
_With this fix we also now properly recognize `CAST(... as TEXT)` and `CAST(... as BOOLEAN)` as `text` and `bool` fields._
|
||||
|
||||
- Fixed `relation` "Cascade delete" tooltip message ([#3098](https://github.com/pocketbase/pocketbase/issues/3098)).
|
||||
|
||||
- Fixed jsvm error message prefix on failed migrations ([#3103](https://github.com/pocketbase/pocketbase/pull/3103); thanks @nzhenev).
|
||||
|
||||
- Disabled the initial Admin UI admins counter cache when there are no initial admins to allow detecting externally created accounts (eg. with the `admin` command) ([#3106](https://github.com/pocketbase/pocketbase/issues/3106)).
|
||||
|
||||
- Downgraded `google/go-cloud` dependency to v0.32.0 until v0.34.0 is released to prevent the `os.TempDir` `cross-device link` errors as too many users complained about it.
|
||||
|
||||
|
||||
## v0.17.3
|
||||
|
||||
- Fixed Docker `cross-device link` error when creating `pb_data` backups on a local mounted volume ([#3089](https://github.com/pocketbase/pocketbase/issues/3089)).
|
||||
|
||||
- Fixed the error messages for relation to views ([#3090](https://github.com/pocketbase/pocketbase/issues/3090)).
|
||||
|
||||
- Always reserve space for the scrollbar to reduce the layout shifts in the Admin UI records listing due to the deprecated `overflow: overlay`.
|
||||
|
||||
- Enabled lazy loading for the Admin UI thumb images.
|
||||
|
||||
|
||||
## v0.17.2
|
||||
|
||||
- Soft-deprecated `$http.send({ data: object, ... })` in favour of `$http.send({ body: rawString, ... })`
|
||||
to allow sending non-JSON body with the request ([#3058](https://github.com/pocketbase/pocketbase/discussions/3058)).
|
||||
The existing `data` prop will still work, but it is recommended to use `body` instead (_to send JSON you can use `JSON.stringify(...)` as body value_).
|
||||
|
||||
- Added `core.RealtimeConnectEvent.IdleTimeout` field to allow specifying a different realtime idle timeout duration per client basis ([#3054](https://github.com/pocketbase/pocketbase/discussions/3054)).
|
||||
|
||||
- Fixed `apis.RequestData` deprecation log note ([#3068](https://github.com/pocketbase/pocketbase/pull/3068); thanks @gungjodi).
|
||||
|
||||
|
||||
## v0.17.1
|
||||
|
||||
- Use relative path when redirecting to the OAuth2 providers page in the Admin UI to support subpath deployments ([#3026](https://github.com/pocketbase/pocketbase/pull/3026); thanks @sonyarianto).
|
||||
|
||||
- Manually trigger the `OnBeforeServe` hook for `tests.ApiScenario` ([#3025](https://github.com/pocketbase/pocketbase/discussions/3025)).
|
||||
|
||||
- Trigger the JSVM `cronAdd()` handler only on app `serve` to prevent unexpected (and eventually duplicated) cron handler calls when custom console commands are used ([#3024](https://github.com/pocketbase/pocketbase/discussions/3024#discussioncomment-6592703)).
|
||||
|
||||
- The `console.log()` messages are now written to the `stdout` instead of `stderr`.
|
||||
|
||||
|
||||
## v0.17.0
|
||||
|
||||
- New more detailed guides for using PocketBase as framework (both Go and JS).
|
||||
_If you find any typos or issues with the docs please report them in https://github.com/pocketbase/site._
|
||||
|
||||
- Added new experimental JavaScript app hooks binding via [goja](https://github.com/dop251/goja).
|
||||
They are available by default with the prebuilt executable if you create `*.pb.js` file(s) in the `pb_hooks` directory.
|
||||
Lower your expectations because the integration comes with some limitations. For more details please check the [Extend with JavaScript](https://pocketbase.io/docs/js-overview/) guide.
|
||||
Optionally, you can also enable the JS app hooks as part of a custom Go build for dynamic scripting but you need to register the `jsvm` plugin manually:
|
||||
```go
|
||||
jsvm.MustRegister(app core.App, config jsvm.Config{})
|
||||
```
|
||||
|
||||
- Added Instagram OAuth2 provider ([#2534](https://github.com/pocketbase/pocketbase/pull/2534); thanks @pnmcosta).
|
||||
|
||||
- Added VK OAuth2 provider ([#2533](https://github.com/pocketbase/pocketbase/pull/2533); thanks @imperatrona).
|
||||
|
||||
- Added Yandex OAuth2 provider ([#2762](https://github.com/pocketbase/pocketbase/pull/2762); thanks @imperatrona).
|
||||
|
||||
- Added new fields to `core.ServeEvent`:
|
||||
```go
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
// new fields
|
||||
Server *http.Server // allows adjusting the HTTP server config (global timeouts, TLS options, etc.)
|
||||
CertManager *autocert.Manager // allows adjusting the autocert options (cache dir, host policy, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
- Added `record.ExpandedOne(rel)` and `record.ExpandedAll(rel)` helpers to retrieve casted single or multiple expand relations from the already loaded "expand" Record data.
|
||||
|
||||
- Added rule and filter record `Dao` helpers:
|
||||
```go
|
||||
app.Dao().FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
|
||||
app.Dao().FindFirstRecordByFilter("posts", "slug='test' && active=true")
|
||||
app.Dao().CanAccessRecord(record, requestInfo, rule)
|
||||
```
|
||||
|
||||
- Added `Dao.WithoutHooks()` helper to create a new `Dao` from the current one but without the create/update/delete hooks.
|
||||
|
||||
- Use a default fetch function that will return all relations in case the `fetchFunc` argument of `Dao.ExpandRecord(record, expands, fetchFunc)` and `Dao.ExpandRecords(records, expands, fetchFunc)` is `nil`.
|
||||
|
||||
- For convenience it is now possible to call `Dao.RecordQuery(collectionModelOrIdentifier)` with just the collection id or name.
|
||||
In case an invalid collection id/name string is passed the query will be resolved with cancelled context error.
|
||||
|
||||
- Refactored `apis.ApiError` validation errors serialization to allow `map[string]error` and `map[string]any` when generating the public safe formatted `ApiError.Data`.
|
||||
|
||||
- Added support for wrapped API errors (_in case Go 1.20+ is used with multiple wrapped errors, the first `apis.ApiError` takes precedence_).
|
||||
|
||||
- Added `?download=1` file query parameter to the file serving endpoint to force the browser to always download the file and not show its preview.
|
||||
|
||||
- Added new utility `github.com/pocketbase/pocketbase/tools/template` subpackage to assist with rendering HTML templates using the standard Go `html/template` and `text/template` syntax.
|
||||
|
||||
- Added `types.JsonMap.Get(k)` and `types.JsonMap.Set(k, v)` helpers for the cases where the type aliased direct map access is not allowed (eg. in [goja](https://pkg.go.dev/github.com/dop251/goja#hdr-Maps_with_methods)).
|
||||
|
||||
- Soft-deprecated `security.NewToken()` in favor of `security.NewJWT()`.
|
||||
|
||||
- `Hook.Add()` and `Hook.PreAdd` now returns a unique string identifier that could be used to remove the registered hook handler via `Hook.Remove(handlerId)`.
|
||||
|
||||
- Changed the after* hooks to be called right before writing the user response, allowing users to return response errors from the after hooks.
|
||||
There is also no longer need for returning explicitly `hook.StopPropagtion` when writing custom response body in a hook because we will skip the finalizer response body write if a response was already "committed".
|
||||
|
||||
- ⚠️ Renamed `*Options{}` to `Config{}` for consistency and replaced the unnecessary pointers with their value equivalent to keep the applied configuration defaults isolated within their function calls:
|
||||
```go
|
||||
old: pocketbase.NewWithConfig(config *pocketbase.Config) *pocketbase.PocketBase
|
||||
new: pocketbase.NewWithConfig(config pocketbase.Config) *pocketbase.PocketBase
|
||||
|
||||
old: core.NewBaseApp(config *core.BaseAppConfig) *core.BaseApp
|
||||
new: core.NewBaseApp(config core.BaseAppConfig) *core.BaseApp
|
||||
|
||||
old: apis.Serve(app core.App, options *apis.ServeOptions) error
|
||||
new: apis.Serve(app core.App, config apis.ServeConfig) (*http.Server, error)
|
||||
|
||||
old: jsvm.MustRegisterMigrations(app core.App, options *jsvm.MigrationsOptions)
|
||||
new: jsvm.MustRegister(app core.App, config jsvm.Config)
|
||||
|
||||
old: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, options *ghupdate.Options)
|
||||
new: ghupdate.MustRegister(app core.App, rootCmd *cobra.Command, config ghupdate.Config)
|
||||
|
||||
old: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, options *migratecmd.Options)
|
||||
new: migratecmd.MustRegister(app core.App, rootCmd *cobra.Command, config migratecmd.Config)
|
||||
```
|
||||
|
||||
- ⚠️ Changed the type of `subscriptions.Message.Data` from `string` to `[]byte` because `Data` usually is a json bytes slice anyway.
|
||||
|
||||
- ⚠️ Renamed `models.RequestData` to `models.RequestInfo` and soft-deprecated `apis.RequestData(c)` in favor of `apis.RequestInfo(c)` to avoid the stuttering with the `Data` field.
|
||||
_The old `apis.RequestData()` method still works to minimize the breaking changes but it is recommended to replace it with `apis.RequestInfo(c)`._
|
||||
|
||||
- ⚠️ Changes to the List/Search APIs
|
||||
- Added new query parameter `?skipTotal=1` to skip the `COUNT` query performed with the list/search actions ([#2965](https://github.com/pocketbase/pocketbase/discussions/2965)).
|
||||
If `?skipTotal=1` is set, the response fields `totalItems` and `totalPages` will have `-1` value (this is to avoid having different JSON responses and to differentiate from the zero default).
|
||||
With the latest JS SDK 0.16+ and Dart SDK v0.11+ versions `skipTotal=1` is set by default for the `getFirstListItem()` and `getFullList()` requests.
|
||||
|
||||
- The count and regular select statements also now executes concurrently, meaning that we no longer perform normalization over the `page` parameter and in case the user
|
||||
request a page that doesn't exist (eg. `?page=99999999`) we'll return empty `items` array.
|
||||
|
||||
- Reverted the default `COUNT` column to `id` as there are some common situations where it can negatively impact the query performance.
|
||||
Additionally, from this version we also set `PRAGMA temp_store = MEMORY` so that also helps with the temp B-TREE creation when `id` is used.
|
||||
_There are still scenarios where `COUNT` queries with `rowid` executes faster, but the majority of the time when nested relations lookups are used it seems to have the opposite effect (at least based on the benchmarks dataset)._
|
||||
|
||||
- ⚠️ Disallowed relations to views **from non-view** collections ([#3000](https://github.com/pocketbase/pocketbase/issues/3000)).
|
||||
The change was necessary because I wasn't able to find an efficient way to track view changes and the previous behavior could have too many unexpected side-effects (eg. view with computed ids).
|
||||
There is a system migration that will convert the existing view `relation` fields to `json` (multiple) and `text` (single) fields.
|
||||
This could be a breaking change if you have `relation` to view and use `expand` or some of the `relation` view fields as part of a collection rule.
|
||||
|
||||
- ⚠️ Added an extra `action` argument to the `Dao` hooks to allow skipping the default persist behavior.
|
||||
In preparation for the logs generalization, the `Dao.After*Func` methods now also allow returning an error.
|
||||
|
||||
- Allowed `0` as `RelationOptions.MinSelect` value to avoid the ambiguity between 0 and non-filled input value ([#2817](https://github.com/pocketbase/pocketbase/discussions/2817)).
|
||||
|
||||
- Fixed zero-default value not being used if the field is not explicitly set when manually creating records ([#2992](https://github.com/pocketbase/pocketbase/issues/2992)).
|
||||
Additionally, `record.Get(field)` will now always return normalized value (the same as in the json serialization) for consistency and to avoid ambiguities with what is stored in the related DB table.
|
||||
The schema fields columns `DEFAULT` definition was also updated for new collections to ensure that `NULL` values can't be accidentally inserted.
|
||||
|
||||
- Fixed `migrate down` not returning the correct `lastAppliedMigrations()` when the stored migration applied time is in seconds.
|
||||
|
||||
- Fixed realtime delete event to be called after the record was deleted from the DB (_including transactions and cascade delete operations_).
|
||||
|
||||
- Other minor fixes and improvements (typos and grammar fixes, updated dependencies, removed unnecessary 404 error check in the Admin UI, etc.).
|
||||
|
||||
|
||||
## v0.16.10
|
||||
|
||||
- Added multiple valued fields (`relation`, `select`, `file`) normalizations to ensure that the zero-default value of a newly created multiple field is applied for already existing data ([#2930](https://github.com/pocketbase/pocketbase/issues/2930)).
|
||||
|
||||
|
||||
## v0.16.9
|
||||
|
||||
- Register the `eagerRequestInfoCache` middleware only for the internal `api` group routes to avoid conflicts with custom route handlers ([#2914](https://github.com/pocketbase/pocketbase/issues/2914)).
|
||||
|
||||
|
||||
## v0.16.8
|
||||
|
||||
- Fixed unique validator detailed error message not being returned when camelCase field name is used ([#2868](https://github.com/pocketbase/pocketbase/issues/2868)).
|
||||
|
||||
- Updated the index parser to allow no space between the table name and the columns list ([#2864](https://github.com/pocketbase/pocketbase/discussions/2864#discussioncomment-6373736)).
|
||||
|
||||
- Updated go deps.
|
||||
|
||||
|
||||
## v0.16.7
|
||||
|
||||
- Minor optimiaztion for the list/search queries to use `rowid` with the `COUNT` statement when available.
|
||||
- Minor optimization for the list/search queries to use `rowid` with the `COUNT` statement when available.
|
||||
_This eliminates the temp B-TREE step when executing the query and for large datasets (eg. 150k) it could have 10x improvement (from ~580ms to ~60ms)._
|
||||
|
||||
|
||||
@@ -25,7 +213,7 @@
|
||||
## v0.16.4
|
||||
|
||||
- Fixed the selfupdate command not working on Windows due to missing `.exe` in the extracted binary path ([#2589](https://github.com/pocketbase/pocketbase/discussions/2589)).
|
||||
_Note that the command on Windows will work on v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._
|
||||
_Note that the command on Windows will work from v0.16.4+ onwards, meaning that you still will have to update manually one more time to v0.16.4._
|
||||
|
||||
- Added `int64`, `int32`, `uint`, `uint64` and `uint32` support when scanning `types.DateTime` ([#2602](https://github.com/pocketbase/pocketbase/discussions/2602))
|
||||
|
||||
@@ -169,7 +357,7 @@
|
||||
It works with a short lived (~5min) file token passed as query param with the file url.
|
||||
For more details and example, you could check https://pocketbase.io/docs/files-handling/#protected-files.
|
||||
|
||||
- **!** Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
|
||||
- ⚠️ Fixed typo in `Record.WithUnkownData()` -> `Record.WithUnknownData()`.
|
||||
|
||||
- Added simple loose wildcard search term support in the Admin UI.
|
||||
|
||||
@@ -260,16 +448,16 @@
|
||||
|
||||
- Added CGO linux target for the prebuilt executable.
|
||||
|
||||
- **!** Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
|
||||
- ⚠️ Renamed `daos.GetTableColumns()` to `daos.TableColumns()` for consistency with the other Dao table related helpers.
|
||||
|
||||
- **!** Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
|
||||
- ⚠️ Renamed `daos.GetTableInfo()` to `daos.TableInfo()` for consistency with the other Dao table related helpers.
|
||||
|
||||
- **!** Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
|
||||
- ⚠️ Changed `types.JsonArray` to support specifying a generic type, aka. `types.JsonArray[T]`.
|
||||
If you have previously used `types.JsonArray`, you'll have to update it to `types.JsonArray[any]`.
|
||||
|
||||
- **!** Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
|
||||
- ⚠️ Registered the `RemoveTrailingSlash` middleware only for the `/api/*` routes since it is causing issues with subpath file serving endpoints ([#2072](https://github.com/pocketbase/pocketbase/issues/2072)).
|
||||
|
||||
- **!** Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
|
||||
- ⚠️ Changed the request logs `method` value to UPPERCASE, eg. "get" => "GET" ([#1956](https://github.com/pocketbase/pocketbase/discussions/1956)).
|
||||
|
||||
- Other minor UI improvements.
|
||||
|
||||
@@ -326,11 +514,11 @@
|
||||
|
||||
- Added `UploadedFiles` field to the `RecordCreateEvent` and `RecordUpdateEvent` event structs.
|
||||
|
||||
- **!** Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
|
||||
- ⚠️ Moved file upload after the record persistent to allow setting custom record id safely from the `OnModelBeforeCreate` hook.
|
||||
|
||||
- **!** Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
|
||||
- ⚠️ Changed `System.GetFile()` to return directly `*blob.Reader` instead of the `io.ReadCloser` interface.
|
||||
|
||||
- **!** Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
|
||||
- ⚠️ Changed `To`, `Cc` and `Bcc` of `mailer.Message` to `[]mail.Address` for consistency and to allow multiple recipients and optional name.
|
||||
|
||||
If you are sending custom emails, you'll have to replace:
|
||||
```go
|
||||
@@ -350,11 +538,11 @@
|
||||
}
|
||||
```
|
||||
|
||||
- **!** Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
|
||||
- ⚠️ Refactored the Authentik integration as a more generic "OpenID Connect" provider (`oidc`) to support any OIDC provider (Okta, Keycloak, etc.).
|
||||
_If you've previously used Authentik, make sure to rename the provider key in your code to `oidc`._
|
||||
_To enable more than one OIDC provider you can use the additional `oidc2` and `oidc3` provider keys._
|
||||
|
||||
- **!** Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
|
||||
- ⚠️ Removed the previously deprecated `Dao.Block()` and `Dao.Continue()` helpers in favor of `Dao.NonconcurrentDB()`.
|
||||
|
||||
- Updated the internal redirects to allow easier subpath deployment when behind a reverse proxy.
|
||||
|
||||
@@ -459,7 +647,7 @@
|
||||
```
|
||||
For all those event hooks `*hook.Hook` was replaced with `*hooks.TaggedHook`, but the hook methods signatures are the same so it should behave as it was previously if no tags were specified.
|
||||
|
||||
- **!** Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
|
||||
- ⚠️ Fixed the `json` field **string** value normalization ([#1703](https://github.com/pocketbase/pocketbase/issues/1703)).
|
||||
|
||||
In order to support seamlessly both `application/json` and `multipart/form-data`
|
||||
requests, the following normalization rules are applied if the `json` field is a
|
||||
|
||||
@@ -4,6 +4,9 @@ lint:
|
||||
test:
|
||||
go test ./... -v --cover
|
||||
|
||||
jstypes:
|
||||
go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
test-report:
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
@@ -146,11 +146,10 @@ You could help continuing its development by:
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
|
||||
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
|
||||
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
|
||||
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
|
||||
|
||||
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
|
||||
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
|
||||
|
||||
+66
-60
@@ -33,18 +33,28 @@ type adminApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
|
||||
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
"token": e.Token,
|
||||
"admin": e.Admin,
|
||||
@@ -62,17 +72,11 @@ func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
@@ -95,17 +99,13 @@ func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -130,29 +130,29 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against admins enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -173,17 +173,17 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -208,6 +208,10 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -228,6 +232,10 @@ func (api *adminApi) view(c echo.Context) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
}
|
||||
@@ -256,17 +264,17 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -302,17 +310,17 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -331,19 +339,17 @@ func (api *adminApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to delete admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
@@ -89,6 +91,26 @@ func TestAdminAuthWithPassword(t *testing.T) {
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -224,6 +246,29 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -278,6 +323,25 @@ func TestAdminRefresh(t *testing.T) {
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -510,6 +574,27 @@ func TestAdminDelete(t *testing.T) {
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
"OnAdminAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -637,6 +722,33 @@ func TestAdminCreate(t *testing.T) {
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890",
|
||||
"avatar":3
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -729,6 +841,7 @@ func TestAdminUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -759,6 +872,33 @@ func TestAdminUpdate(t *testing.T) {
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891",
|
||||
"avatar":5
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeUpdateRequest": 1,
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+50
-26
@@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {
|
||||
|
||||
// NewApiError creates and returns new normalized `ApiError` instance.
|
||||
func NewApiError(status int, message string, data any) *ApiError {
|
||||
message = inflector.Sentenize(message)
|
||||
|
||||
formattedData := map[string]any{}
|
||||
|
||||
if v, ok := data.(validation.Errors); ok {
|
||||
formattedData = resolveValidationErrors(v)
|
||||
}
|
||||
|
||||
return &ApiError{
|
||||
rawData: data,
|
||||
Data: formattedData,
|
||||
Data: safeErrorsData(data),
|
||||
Code: status,
|
||||
Message: strings.TrimSpace(message),
|
||||
Message: strings.TrimSpace(inflector.Sentenize(message)),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
|
||||
func safeErrorsData(data any) map[string]any {
|
||||
switch v := data.(type) {
|
||||
case validation.Errors:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]validation.Error:
|
||||
return resolveSafeErrorsData[validation.Error](v)
|
||||
case map[string]error:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]any:
|
||||
return resolveSafeErrorsData[any](v)
|
||||
default:
|
||||
return map[string]any{} // not nil to ensure that is json serialized as object
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
// extract from each validation error its error code and message.
|
||||
for name, err := range validationErrors {
|
||||
// check for nested errors
|
||||
if nestedErrs, ok := err.(validation.Errors); ok {
|
||||
result[name] = resolveValidationErrors(nestedErrs)
|
||||
for name, err := range data {
|
||||
if isNestedError(err) {
|
||||
result[name] = safeErrorsData(err)
|
||||
continue
|
||||
}
|
||||
|
||||
errCode := "validation_invalid_value" // default
|
||||
if errObj, ok := err.(validation.ErrorObject); ok {
|
||||
errCode = errObj.Code()
|
||||
}
|
||||
|
||||
result[name] = map[string]string{
|
||||
"code": errCode,
|
||||
"message": inflector.Sentenize(err.Error()),
|
||||
}
|
||||
result[name] = resolveSafeErrorItem(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isNestedError(err any) bool {
|
||||
switch err.(type) {
|
||||
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveSafeErrorItem extracts from each validation error its
|
||||
// public safe error code and message.
|
||||
func resolveSafeErrorItem(err any) map[string]string {
|
||||
// default public safe error values
|
||||
code := "validation_invalid_value"
|
||||
msg := "Invalid value."
|
||||
|
||||
// only validation errors are public safe
|
||||
if obj, ok := err.(validation.Error); ok {
|
||||
code = obj.Code()
|
||||
msg = inflector.Sentenize(obj.Error())
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"code": code,
|
||||
"message": msg,
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -37,10 +37,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
"err1": errors.New("test error"),
|
||||
"err1": errors.New("test error"), // should be normalized
|
||||
"err2": validation.ErrRequired,
|
||||
"err3": validation.Errors{
|
||||
"sub1": errors.New("test error"),
|
||||
"sub1": errors.New("test error"), // should be normalized
|
||||
"sub2": validation.ErrRequired,
|
||||
"sub3": validation.Errors{
|
||||
"sub11": validation.ErrRequired,
|
||||
@@ -50,10 +50,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
)
|
||||
|
||||
result, _ := json.Marshal(e)
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(result))
|
||||
t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
|
||||
}
|
||||
|
||||
if e.Error() != "Message_test." {
|
||||
@@ -73,7 +73,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -81,7 +81,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -102,7 +102,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -123,7 +123,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -144,7 +144,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -132,8 +131,7 @@ func (api *backupApi) restore(c echo.Context) error {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
|
||||
}
|
||||
|
||||
// @todo remove the extra unescape after https://github.com/labstack/echo/issues/2447
|
||||
key, _ := url.PathUnescape(c.PathParam("key"))
|
||||
key := c.PathParam("key")
|
||||
|
||||
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+56
-5
@@ -300,7 +300,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with valid admin file token but missing backup name",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/mizzing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -325,6 +325,22 @@ func TestBackupsDownload(t *testing.T) {
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token and backup name with escaped char",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`storage/`,
|
||||
`data.db`,
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -339,7 +355,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := 3
|
||||
expected := 4
|
||||
if total := len(files); total != expected {
|
||||
t.Fatalf("Expected %d backup(s), got %d", expected, total)
|
||||
}
|
||||
@@ -411,7 +427,6 @@ func TestBackupsDelete(t *testing.T) {
|
||||
|
||||
// mock active backup with the same name to delete
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
|
||||
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
@@ -440,8 +455,8 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 2 {
|
||||
t.Fatalf("Expected 2 backup files, got %d", total)
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "test1.zip"
|
||||
@@ -454,6 +469,38 @@ func TestBackupsDelete(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (backup with escaped character)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/%40test4.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "@test4.zip"
|
||||
|
||||
for _, f := range files {
|
||||
if f.Key == deletedFile {
|
||||
t.Fatalf("Expected backup %q to be deleted", deletedFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -546,6 +593,10 @@ func createTestBackups(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+56
-53
@@ -2,6 +2,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -34,6 +35,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
AllowOverwritingRoute: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,12 +46,16 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
|
||||
},
|
||||
}))
|
||||
e.Pre(LoadAuthContext(app))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
e.Use(LoadAuthContext(app))
|
||||
|
||||
// custom error handler
|
||||
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
||||
if err == nil {
|
||||
return // no error
|
||||
}
|
||||
|
||||
if c.Response().Committed {
|
||||
if app.IsDebug() {
|
||||
log.Println("HTTPErrorHandler response was already committed:", err)
|
||||
@@ -59,23 +65,26 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
var apiErr *ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
if errors.As(err, &apiErr) {
|
||||
if app.IsDebug() && apiErr.RawData() != nil {
|
||||
log.Println(apiErr.RawData())
|
||||
}
|
||||
} else if v := new(echo.HTTPError); errors.As(err, &v) {
|
||||
if v.Internal != nil && app.IsDebug() {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
case *ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
}
|
||||
apiErr = v
|
||||
default:
|
||||
if err != nil && app.IsDebug() {
|
||||
} else {
|
||||
if app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
apiErr = NewBadRequestError("", err)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
apiErr = NewNotFoundError("", err)
|
||||
} else {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.ApiErrorEvent)
|
||||
@@ -84,6 +93,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
if c.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
@@ -92,19 +105,21 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
if hookErr == nil {
|
||||
if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
}
|
||||
} else if app.IsDebug() {
|
||||
// truly rare case; eg. client already disconnected
|
||||
log.Println(hookErr)
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
bindStaticAdminUI(app, e)
|
||||
|
||||
// default routes
|
||||
api := e.Group("/api")
|
||||
api := e.Group("/api", eagerRequestInfoCache(app))
|
||||
bindSettingsApi(app, api)
|
||||
bindAdminApi(app, api)
|
||||
bindCollectionApi(app, api)
|
||||
@@ -116,20 +131,6 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindHealthApi(app, api)
|
||||
bindBackupApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: e,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// note: it is after the OnBeforeServe hook to ensure that the implicit
|
||||
// cache is after any user custom defined middlewares
|
||||
e.Use(eagerRequestDataCache(app))
|
||||
|
||||
// catch all any route
|
||||
api.Any("/*", func(c echo.Context) error {
|
||||
return echo.ErrNotFound
|
||||
@@ -192,19 +193,6 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const totalAdminsCacheKey = "@totalAdmins"
|
||||
|
||||
func updateTotalAdminsCache(app core.App) error {
|
||||
total, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Cache().Set(totalAdminsCacheKey, total)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uiCacheControl() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
@@ -219,15 +207,29 @@ func uiCacheControl() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdminsCacheKey = "@hasAdmins"
|
||||
|
||||
func updateHasAdminsCache(app core.App) error {
|
||||
total, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Cache().Set(hasAdminsCacheKey, total > 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer admin UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
// keep totalAdminsCacheKey value up-to-date
|
||||
// keep hasAdminsCacheKey value up-to-date
|
||||
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -238,23 +240,24 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// load into cache (if not already)
|
||||
if !app.Cache().Has(totalAdminsCacheKey) {
|
||||
if err := updateTotalAdminsCache(app); err != nil {
|
||||
hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
|
||||
|
||||
if !hasAdmins {
|
||||
// update the cache to make sure that the admin wasn't created by another process
|
||||
if err := updateHasAdminsCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
|
||||
}
|
||||
|
||||
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
|
||||
|
||||
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
||||
|
||||
if totalAdmins == 0 && !hasInstallerParam {
|
||||
if !hasAdmins && !hasInstallerParam {
|
||||
// redirect to the installer page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
|
||||
}
|
||||
|
||||
if totalAdmins != 0 && hasInstallerParam {
|
||||
if hasAdmins && hasInstallerParam {
|
||||
// clear the installer param
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
+150
-43
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -213,16 +214,16 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerRequestDataCache(t *testing.T) {
|
||||
func TestEagerRequestInfoCache(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "[UNKNOWN] unsupported eager cached request method",
|
||||
Method: "UNKNOWN",
|
||||
Name: "custom non-api group route",
|
||||
Method: "POST",
|
||||
Url: "/custom",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: "UNKNOWN",
|
||||
Method: "POST",
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
@@ -235,57 +236,163 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
|
||||
// since the unknown method is not eager cache support
|
||||
// it should fail reading the json body twice
|
||||
r := apis.RequestData(c)
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "" {
|
||||
t.Fatalf("Expected empty request data body, got, %v", r.Data)
|
||||
}
|
||||
|
||||
return c.String(200, data.Name)
|
||||
return c.NoContent(200)
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
}
|
||||
{
|
||||
Name: "api group route with unsupported eager cache request method",
|
||||
Method: "GET",
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// it is not important whether the route handler return an error since
|
||||
// we just need to ensure that the eagerRequestInfoCache was registered
|
||||
next(c)
|
||||
|
||||
// supported eager cache request methods
|
||||
supportedMethods := []string{"POST", "PUT", "PATCH", "DELETE"}
|
||||
for _, m := range supportedMethods {
|
||||
scenarios = append(
|
||||
scenarios,
|
||||
tests.ApiScenario{
|
||||
Name: fmt.Sprintf("[%s] valid cached json body request", m),
|
||||
Method: http.MethodPost,
|
||||
Url: "/custom",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
// ensure that the body was read at least once
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
c.Bind(data)
|
||||
|
||||
if err := c.Bind(data); err != nil {
|
||||
return err
|
||||
}
|
||||
// since the unknown method is not eager cache support
|
||||
// it should fail reading the json body twice
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "" {
|
||||
t.Fatalf("Expected empty request data body, got, %v", r.Data)
|
||||
}
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestData(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return c.String(200, data.Name)
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
return nil
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
{
|
||||
Name: "api group route with supported eager cache request method",
|
||||
Method: "POST",
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"name":"test123"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// it is not important whether the route handler return an error since
|
||||
// we just need to ensure that the eagerRequestInfoCache was registered
|
||||
next(c)
|
||||
|
||||
// ensure that the body was read at least once
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
c.Bind(data)
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return apis.NewApiError(418, "test", nil)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(418, "test")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "wrapped sql.ErrNoRows",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", sql.ErrNoRows)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "custom error",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+40
-41
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -49,6 +48,10 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -64,6 +67,10 @@ func (api *collectionApi) view(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
}
|
||||
@@ -83,7 +90,7 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -92,18 +99,16 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) update(c echo.Context) error {
|
||||
@@ -124,7 +129,7 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -133,18 +138,16 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) delete(c echo.Context) error {
|
||||
@@ -157,21 +160,19 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
@@ -187,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
@@ -196,16 +197,14 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
+126
-7
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
@@ -45,7 +47,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -55,6 +57,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
`"id":"ib3m2700k5hlsjz"`,
|
||||
`"type":"auth"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -73,9 +76,9 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"kpv709sk2lqbqk8"`,
|
||||
`"id":"v9gwnfh02gjq1q0"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -299,7 +302,6 @@ func TestCollectionDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
@@ -308,6 +310,27 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/view2",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -536,6 +559,28 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"options":{"minPasswordLength":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
@@ -720,6 +765,28 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid data (eg. existing name)",
|
||||
Method: http.MethodPatch,
|
||||
@@ -1098,7 +1165,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImport(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -1355,8 +1422,8 @@ func TestCollectionsImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 8,
|
||||
"OnModelAfterDelete": 8,
|
||||
"OnModelBeforeDelete": 9,
|
||||
"OnModelAfterDelete": 9,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
@@ -1373,6 +1440,58 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + successful collections save",
|
||||
Method: http.MethodPut,
|
||||
Url: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"collections":[
|
||||
{
|
||||
"name": "import1",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "auth_without_schema",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+27
-62
@@ -3,19 +3,15 @@ package apis
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -49,23 +45,21 @@ func (api *fileApi) fileToken(c echo.Context) error {
|
||||
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
return api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.Model == nil || e.Token == "" {
|
||||
return NewBadRequestError("Failed to generate file token.", nil)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
return api.app.OnFileAfterTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnFileAfterTokenRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
@@ -102,7 +96,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
|
||||
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
|
||||
|
||||
if !api.canAccessRecord(adminOrAuthRecord, record, record.Collection().ViewRule) {
|
||||
// create a copy of the cached request data and adjust it for the current auth model
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = nil
|
||||
if adminOrAuthRecord != nil {
|
||||
if admin, _ := adminOrAuthRecord.(*models.Admin); admin != nil {
|
||||
requestInfo.Admin = admin
|
||||
} else if record, _ := adminOrAuthRecord.(*models.Record); record != nil {
|
||||
requestInfo.AuthRecord = record
|
||||
}
|
||||
}
|
||||
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
|
||||
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
|
||||
}
|
||||
}
|
||||
@@ -166,9 +172,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
c.Response().Header().Del("X-Frame-Options")
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
@@ -206,46 +214,3 @@ func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.M
|
||||
|
||||
return nil, errors.New("missing or invalid file token")
|
||||
}
|
||||
|
||||
// @todo move to a helper and maybe combine with the realtime checks when refactoring the realtime service
|
||||
func (api *fileApi) canAccessRecord(adminOrAuthRecord models.Model, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := adminOrAuthRecord.(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = adminOrAuthRecord.(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+8
-6
@@ -287,8 +287,10 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
|
||||
logsMaxDays := app.Settings().Logs.MaxDays
|
||||
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
if logsMaxDays == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -348,8 +350,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
|
||||
if daysDiff > float64(app.Settings().Logs.MaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
|
||||
if daysDiff > float64(logsMaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*logsMaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Cache().Set("lastLogsDeletedAt", now)
|
||||
} else if app.IsDebug() {
|
||||
@@ -393,15 +395,15 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
return fallbackIp
|
||||
}
|
||||
|
||||
// eagerRequestDataCache ensures that the request data is cached in the request
|
||||
// eagerRequestInfoCache ensures that the request data is cached in the request
|
||||
// context to allow reading for example the json request body data more than once.
|
||||
func eagerRequestDataCache(app core.App) echo.MiddlewareFunc {
|
||||
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
switch c.Request().Method {
|
||||
// currently we are eagerly caching only the requests with body
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
RequestData(c)
|
||||
RequestInfo(c)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
|
||||
+73
-47
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -67,6 +66,7 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
IdleTimeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
@@ -83,16 +83,18 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
Data: []byte(`{"clientId":"` + client.Id() + `"}`),
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
@@ -100,13 +102,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
idleTimer := time.NewTimer(idleDuration)
|
||||
idleTimeout := connectEvent.IdleTimeout
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
|
||||
for {
|
||||
@@ -129,11 +128,13 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + e.Client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
@@ -142,12 +143,8 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
idleTimer.Reset(idleTimeout)
|
||||
case <-c.Request().Context().Done():
|
||||
// connection is closed
|
||||
if api.app.IsDebug() {
|
||||
@@ -191,7 +188,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
Subscriptions: form.Subscriptions,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
// update auth state
|
||||
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
|
||||
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
|
||||
@@ -202,14 +199,14 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
// subscribe to the new subscriptions
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRealtimeAfterSubscribeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
|
||||
@@ -269,7 +266,7 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +275,7 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +284,16 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -340,12 +346,12 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
}
|
||||
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
requestInfo := &models.RequestInfo{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -369,7 +375,7 @@ type recordData struct {
|
||||
Record *models.Record `json:"record"`
|
||||
}
|
||||
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error {
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("Record collection not set.")
|
||||
@@ -401,14 +407,9 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
encodedData := string(dataBytes)
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
@@ -423,7 +424,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: encodedData,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
// ignore the auth record email visibility checks for
|
||||
@@ -434,23 +435,48 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
|
||||
data.Record.IgnoreEmailVisibility(true) // ignore
|
||||
if newData, err := json.Marshal(data); err == nil {
|
||||
msg.Data = string(newData)
|
||||
msg.Data = newData
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
}
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
if dryCache {
|
||||
client.Set(action+"/"+data.Record.Id, msg)
|
||||
} else {
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastDryCachedRecord broadcasts record if it is cached in the client context.
|
||||
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
|
||||
for _, client := range clients {
|
||||
key := action + "/" + record.Id
|
||||
|
||||
msg, ok := client.Get(key).(subscriptions.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
client.Unset(key)
|
||||
|
||||
client := client
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type getter interface {
|
||||
Get(string) any
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
|
||||
+78
-89
@@ -65,17 +65,11 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
@@ -139,9 +133,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
}
|
||||
|
||||
if name == auth.NameApple {
|
||||
// custom providers url options
|
||||
switch name {
|
||||
case auth.NameApple:
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
|
||||
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
|
||||
case auth.NameVK:
|
||||
// vk currently doesn't support PKCE for server-side authorization
|
||||
urlOpts = []oauth2.AuthCodeOption{}
|
||||
}
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
@@ -192,8 +191,8 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
event.IsNewRecord = true
|
||||
// clone the current request data and assign the form create data as its body data
|
||||
requestData := *RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -206,7 +205,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
}
|
||||
|
||||
if *collection.CreateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -251,17 +250,13 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
IsNew: event.IsNewRecord,
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
|
||||
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -291,17 +286,13 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -341,25 +332,24 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -386,17 +376,17 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -431,25 +421,24 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against users enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
@@ -476,17 +465,17 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -511,23 +500,23 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
@@ -554,17 +543,17 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -628,19 +617,19 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return NewBadRequestError("Cannot unlink the external auth provider.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -672,10 +661,10 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: oauth2SubscriptionTopic,
|
||||
Data: string(encodedData),
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
client.Send(msg)
|
||||
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
|
||||
}
|
||||
|
||||
+135
-9
@@ -2,12 +2,14 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -280,6 +282,28 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// after hooks error checks
|
||||
{
|
||||
Name: "OnRecordAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2_username",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -353,6 +377,25 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-refresh?expand=rel,missing",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -494,10 +537,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -530,6 +571,29 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -671,10 +735,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -732,6 +794,27 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmVerificationRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -932,6 +1015,28 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
`"token":{"code":"validation_token_collection_mismatch"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmEmailChangeRequest": 1,
|
||||
"OnRecordAfterConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -1140,6 +1245,27 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -1236,7 +1362,7 @@ func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
|
||||
expectedParams := []string{`"state"`, `"code"`}
|
||||
for _, p := range expectedParams {
|
||||
if !strings.Contains(msg.Data, p) {
|
||||
if !strings.Contains(string(msg.Data), p) {
|
||||
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
|
||||
}
|
||||
}
|
||||
|
||||
+62
-65
@@ -50,9 +50,9 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
@@ -60,20 +60,15 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(
|
||||
api.app.Dao(),
|
||||
collection,
|
||||
requestData,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by admins
|
||||
requestData.Admin != nil,
|
||||
requestInfo.Admin != nil,
|
||||
)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
|
||||
// views don't have "rowid" so we fallback to "id"
|
||||
if collection.IsView() {
|
||||
searchProvider.CountCol("id")
|
||||
}
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
@@ -91,6 +86,10 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -110,16 +109,16 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ViewRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -141,6 +140,10 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
@@ -155,23 +158,23 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.CreateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
hasFullManageAccess := requestData.Admin != nil
|
||||
hasFullManageAccess := requestInfo.Admin != nil
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
// available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
@@ -185,7 +188,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,7 +203,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -225,7 +228,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -238,18 +241,16 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
@@ -263,26 +264,26 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.UpdateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
// and available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -300,7 +301,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
|
||||
form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
@@ -314,7 +315,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -327,18 +328,16 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
@@ -352,16 +351,16 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.DeleteRule == nil {
|
||||
if requestInfo.Admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -382,22 +381,20 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
@@ -460,6 +462,22 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view collection with numeric ids",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"id":"1"`,
|
||||
`"id":"2"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -729,6 +747,16 @@ func TestRecordCrudView(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view record with numeric id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records/1",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"1"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -836,6 +864,27 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
Method: http.MethodDelete,
|
||||
@@ -1187,6 +1236,25 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -1745,6 +1813,25 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "try to change the id of an existing record",
|
||||
Method: http.MethodPatch,
|
||||
|
||||
+54
-27
@@ -17,19 +17,28 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
const ContextRequestInfoKey = "requestInfo"
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
// Deprecated: Use RequestInfo instead.
|
||||
func RequestData(c echo.Context) *models.RequestInfo {
|
||||
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
|
||||
return RequestInfo(c)
|
||||
}
|
||||
|
||||
// RequestInfo exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
func RequestInfo(c echo.Context) *models.RequestInfo {
|
||||
// return cached to avoid copying the body multiple times
|
||||
if v := c.Get(ContextRequestDataKey); v != nil {
|
||||
if data, ok := v.(*models.RequestData); ok {
|
||||
if v := c.Get(ContextRequestInfoKey); v != nil {
|
||||
if data, ok := v.(*models.RequestInfo); ok {
|
||||
// refresh auth state
|
||||
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
result := &models.RequestInfo{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
@@ -49,12 +58,20 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
echo.BindQueryParams(c, &result.Query)
|
||||
rest.BindBody(c, &result.Data)
|
||||
|
||||
c.Set(ContextRequestDataKey, result)
|
||||
c.Set(ContextRequestInfoKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
// RecordAuthResponse writes standardised json record auth response
|
||||
// into the specified request context.
|
||||
func RecordAuthResponse(
|
||||
app core.App,
|
||||
c echo.Context,
|
||||
authRecord *models.Record,
|
||||
meta any,
|
||||
finalizers ...func(token string) error,
|
||||
) error {
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
@@ -68,6 +85,10 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow always returning the email address of the authenticated account
|
||||
e.Record.IgnoreEmailVisibility(true)
|
||||
|
||||
@@ -75,13 +96,13 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
// create a copy of the cached request data and adjust it to the current auth record
|
||||
requestData := *RequestData(e.HttpContext)
|
||||
requestData.Admin = nil
|
||||
requestData.AuthRecord = e.Record
|
||||
requestInfo := *RequestInfo(e.HttpContext)
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = e.Record
|
||||
failed := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
expandFetch(app.Dao(), &requestInfo),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
@@ -97,6 +118,12 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(e.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -114,9 +141,9 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
|
||||
// - ensures that the emails of the auth records and their expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
@@ -128,7 +155,7 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
return nil // nothing to expand
|
||||
}
|
||||
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Failed to expand: %v", errs)
|
||||
}
|
||||
@@ -139,11 +166,11 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(
|
||||
dao *daos.Dao,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) daos.ExpandFetchFunc {
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
return nil // admins can access everything
|
||||
}
|
||||
|
||||
@@ -152,7 +179,7 @@ func expandFetch(
|
||||
}
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -165,7 +192,7 @@ func expandFetch(
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
|
||||
}
|
||||
|
||||
return records, err
|
||||
@@ -179,13 +206,13 @@ func expandFetch(
|
||||
func autoIgnoreAuthRecordsEmailVisibility(
|
||||
dao *daos.Dao,
|
||||
records []*models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) error {
|
||||
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
for _, rec := range records {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
@@ -201,8 +228,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
@@ -218,7 +245,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -247,7 +274,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
func hasAuthManageAccess(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
@@ -259,12 +286,12 @@ func hasAuthManageAccess(
|
||||
return false // only for admins (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestData == nil || requestData.AuthRecord == nil {
|
||||
if requestInfo == nil || requestInfo.AuthRecord == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestData(t *testing.T) {
|
||||
func TestRequestInfo(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
@@ -29,10 +29,10 @@ func TestRequestData(t *testing.T) {
|
||||
dummyAdmin.Id = "id2"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
result := apis.RequestData(c)
|
||||
result := apis.RequestInfo(c)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected *models.RequestData instance, got nil")
|
||||
t.Fatal("Expected *models.RequestInfo instance, got nil")
|
||||
}
|
||||
|
||||
if result.Method != http.MethodPost {
|
||||
|
||||
+42
-36
@@ -21,28 +21,30 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// ServeOptions defines an optional struct for apis.Serve().
|
||||
type ServeOptions struct {
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
ShowStartBanner bool
|
||||
HttpAddr string
|
||||
HttpsAddr string
|
||||
AllowedOrigins []string // optional list of CORS origins (default to "*")
|
||||
BeforeServeFunc func(server *http.Server) error
|
||||
|
||||
// HttpAddr is the HTTP server address to bind (eg. `127.0.0.1:80`).
|
||||
HttpAddr string
|
||||
|
||||
// HttpsAddr is the HTTPS server address to bind (eg. `127.0.0.1:443`).
|
||||
HttpsAddr string
|
||||
|
||||
// AllowedOrigins is an optional list of CORS origins (default to "*").
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Serve starts a new app web server.
|
||||
func Serve(app core.App, options *ServeOptions) error {
|
||||
if options == nil {
|
||||
options = &ServeOptions{}
|
||||
}
|
||||
|
||||
if len(options.AllowedOrigins) == 0 {
|
||||
options.AllowedOrigins = []string{"*"}
|
||||
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
if len(config.AllowedOrigins) == 0 {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
@@ -56,32 +58,32 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
|
||||
router, err := InitApi(app)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: options.AllowedOrigins,
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := options.HttpAddr
|
||||
if options.HttpsAddr != "" {
|
||||
mainAddr = options.HttpsAddr
|
||||
mainAddr := config.HttpAddr
|
||||
if config.HttpsAddr != "" {
|
||||
mainAddr = config.HttpsAddr
|
||||
}
|
||||
|
||||
mainHost, _, _ := net.SplitHostPort(mainAddr)
|
||||
|
||||
certManager := autocert.Manager{
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
|
||||
}
|
||||
|
||||
serverConfig := &http.Server{
|
||||
server := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
@@ -93,15 +95,19 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
Addr: mainAddr,
|
||||
}
|
||||
|
||||
if options.BeforeServeFunc != nil {
|
||||
if err := options.BeforeServeFunc(serverConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: router,
|
||||
Server: server,
|
||||
CertManager: certManager,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if options.ShowStartBanner {
|
||||
if config.ShowStartBanner {
|
||||
schema := "http"
|
||||
if options.HttpsAddr != "" {
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
}
|
||||
|
||||
@@ -112,34 +118,34 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, serverConfig.Addr),
|
||||
color.CyanString("%s://%s", schema, server.Addr),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, server.Addr))
|
||||
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, server.Addr))
|
||||
}
|
||||
|
||||
// try to gracefully shutdown the server on app termination
|
||||
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
serverConfig.Shutdown(ctx)
|
||||
server.Shutdown(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
// start HTTPS server
|
||||
if options.HttpsAddr != "" {
|
||||
if config.HttpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if options.HttpAddr != "" {
|
||||
go http.ListenAndServe(options.HttpAddr, certManager.HTTPHandler(nil))
|
||||
if config.HttpAddr != "" {
|
||||
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
|
||||
}
|
||||
|
||||
return serverConfig.ListenAndServeTLS("", "")
|
||||
return server, server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// OR start HTTP server
|
||||
return serverConfig.ListenAndServe()
|
||||
return server, server.ListenAndServe()
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
|
||||
+16
-15
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -38,6 +37,10 @@ func (api *settingsApi) list(c echo.Context) error {
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
})
|
||||
}
|
||||
@@ -55,7 +58,7 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
@@ -64,23 +67,21 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *settingsApi) testS3(c echo.Context) error {
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -75,6 +77,9 @@ func TestSettingsList(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -153,6 +158,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -220,6 +228,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -231,6 +242,28 @@ func TestSettingsSet(t *testing.T) {
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnSettingsBeforeUpdateRequest": 1,
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
Use: "serve",
|
||||
Short: "Starts the web server (default to 127.0.0.1:8090)",
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := apis.Serve(app, &apis.ServeOptions{
|
||||
_, err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
ShowStartBanner: showStartBanner,
|
||||
|
||||
+76
-85
@@ -74,14 +74,14 @@ type App interface {
|
||||
// NewFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing regular app files (eg. collection uploads).
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewFilesystem() (*filesystem.System, error)
|
||||
|
||||
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing app backups.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewBackupsFilesystem() (*filesystem.System, error)
|
||||
|
||||
@@ -131,21 +131,21 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// OnBeforeBootstrap hook is triggered before initializing the main
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// OnAfterBootstrap hook is triggered after initializing the main
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
// allowing you to adjust its options and attach new routes or middlewares.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
// or to return a completely different API response.
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
@@ -162,7 +162,7 @@ type App interface {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnModelBeforeCreate hook is triggered before inserting a new
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -170,7 +170,7 @@ type App interface {
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
// inserting a new model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -178,7 +178,7 @@ type App interface {
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeUpdate hook is triggered before updating existing
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -186,7 +186,7 @@ type App interface {
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
// existing model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -194,15 +194,15 @@ type App interface {
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
// OnModelAfterDelete hook is triggered after successfully deleting an
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -213,22 +213,18 @@ type App interface {
|
||||
// Mailer event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an admin.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an admin, allowing you
|
||||
// to inspect and customize the email message that is being sent.
|
||||
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerAfterAdminResetPasswordSend hook is triggered after
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -243,11 +239,9 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right
|
||||
// before sending a verification email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -263,10 +257,8 @@ type App interface {
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// sending a confirmation new address email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -328,8 +320,7 @@ type App interface {
|
||||
// Settings update request (after request data load and before settings persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or
|
||||
// implement completely different persistence behavior
|
||||
// (returning [hook.StopPropagation]).
|
||||
// implement completely different persistence behavior.
|
||||
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
|
||||
|
||||
// OnSettingsAfterUpdateRequest hook is triggered after each
|
||||
@@ -383,7 +374,7 @@ type App interface {
|
||||
// Admin create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
|
||||
|
||||
// OnAdminAfterCreateRequest hook is triggered after each
|
||||
@@ -394,7 +385,7 @@ type App interface {
|
||||
// Admin update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
|
||||
|
||||
// OnAdminAfterUpdateRequest hook is triggered after each
|
||||
@@ -405,7 +396,7 @@ type App interface {
|
||||
// Admin delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
|
||||
|
||||
// OnAdminAfterDeleteRequest hook is triggered after each
|
||||
@@ -434,7 +425,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
@@ -445,7 +436,7 @@ type App interface {
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
@@ -456,7 +447,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
@@ -482,7 +473,7 @@ type App interface {
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
// or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -500,11 +491,11 @@ type App interface {
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
// change the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -523,7 +514,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -538,11 +529,39 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -561,7 +580,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -580,7 +599,7 @@ type App interface {
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
// completely different verification behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -599,7 +618,7 @@ type App interface {
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -618,7 +637,7 @@ type App interface {
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
// completely different request email change behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -637,7 +656,7 @@ type App interface {
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -652,34 +671,6 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -706,7 +697,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -725,7 +716,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -744,7 +735,7 @@ type App interface {
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -777,7 +768,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
|
||||
|
||||
// OnCollectionAfterCreateRequest hook is triggered after each
|
||||
@@ -788,7 +779,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
|
||||
|
||||
// OnCollectionAfterUpdateRequest hook is triggered after each
|
||||
@@ -799,7 +790,7 @@ type App interface {
|
||||
// Collection delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
|
||||
|
||||
// OnCollectionAfterDeleteRequest hook is triggered after each
|
||||
@@ -810,7 +801,7 @@ type App interface {
|
||||
// collections import request (after request data load and before the actual import).
|
||||
//
|
||||
// Could be used to additionally validate the imported collections or
|
||||
// to implement completely different import behavior (returning [hook.StopPropagation]).
|
||||
// to implement completely different import behavior.
|
||||
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
|
||||
|
||||
// OnCollectionsAfterImportRequest hook is triggered after each
|
||||
|
||||
+22
-26
@@ -180,7 +180,7 @@ type BaseAppConfig struct {
|
||||
// configured with the provided arguments.
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
@@ -349,11 +349,7 @@ func (app *BaseApp) Bootstrap() error {
|
||||
// cleanup the pb_data temp directory (if any)
|
||||
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
|
||||
|
||||
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return app.OnAfterBootstrap().Trigger(event)
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -485,7 +481,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
// for managing regular app files (eg. collection uploads)
|
||||
// based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.S3.Enabled {
|
||||
@@ -506,7 +502,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing app backups based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.Backups.S3.Enabled {
|
||||
@@ -1053,58 +1049,58 @@ func (app *BaseApp) initDataDB() error {
|
||||
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeCreate().Trigger(e)
|
||||
return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterCreate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeUpdate().Trigger(e)
|
||||
return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterUpdate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeDelete().Trigger(e)
|
||||
return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterDelete().Trigger(e)
|
||||
}
|
||||
|
||||
return dao
|
||||
|
||||
+25
-15
@@ -31,6 +31,9 @@ const CacheKeyActiveBackup string = "@activeBackup"
|
||||
// The backup is executed within a transaction, meaning that new writes
|
||||
// will be temporary "blocked" until the backup file is generated.
|
||||
//
|
||||
// To safely perform the backup, it is recommended to have free disk space
|
||||
// for at least 2x the size of the pb_data directory.
|
||||
//
|
||||
// By default backups are stored in pb_data/backups
|
||||
// (the backups directory itself is excluded from the generated backup).
|
||||
//
|
||||
@@ -54,16 +57,21 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
|
||||
//
|
||||
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
|
||||
// ---
|
||||
tempPath := filepath.Join(os.TempDir(), "pb_backup_"+security.PseudorandomString(4))
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
|
||||
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := archive.Create(app.DataDir(), tempPath, LocalBackupsDirName); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
|
||||
return archive.Create(app.DataDir(), tempPath, LocalBackupsDirName)
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
@@ -118,7 +126,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// 4. Move the extracted dir content to the app "pb_data".
|
||||
//
|
||||
// 5. Restart the app (on successfull app bootstap it will also remove the old pb_data).
|
||||
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
@@ -149,7 +157,15 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
tempZip, err := os.CreateTemp(os.TempDir(), "pb_restore")
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,13 +175,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// make sure that the special temp directory
|
||||
if err := os.MkdirAll(filepath.Join(app.DataDir(), LocalTempDirName), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
extractedDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "pb_restore_"+security.PseudorandomString(4))
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
@@ -189,7 +199,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
// move the current pb_data content to a special temp location
|
||||
// that will hold the old data between dirs replace
|
||||
// (the temp dir will be automatically removed on the next app start)
|
||||
oldTempDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "old_pb_data_"+security.PseudorandomString(4))
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
|
||||
if err := osutils.MoveDirContent(app.DataDir(), oldTempDataDir, exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ func verifyBackupContent(app core.App, path string) error {
|
||||
"logs.db-shm",
|
||||
"logs.db-wal",
|
||||
".gitignore",
|
||||
".pb_temp_to_delete",
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
+6
-6
@@ -11,7 +11,7 @@ func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
@@ -46,7 +46,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
@@ -128,7 +128,7 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
@@ -188,7 +188,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
@@ -211,7 +211,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
@@ -241,7 +241,7 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
|
||||
@@ -28,6 +28,8 @@ func init() {
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -16000;
|
||||
`, nil)
|
||||
|
||||
return err
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
// Note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection.
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)"
|
||||
pragmas := "?_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)"
|
||||
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
|
||||
+9
-2
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,8 +74,10 @@ type TerminateEvent struct {
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
App App
|
||||
Router *echo.Echo
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
@@ -116,6 +122,7 @@ type MailerAdminEvent struct {
|
||||
type RealtimeConnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
|
||||
+133
-93
@@ -5,6 +5,8 @@ package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -29,7 +31,8 @@ func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
//
|
||||
// You can think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
@@ -43,12 +46,13 @@ type Dao struct {
|
||||
// This field has no effect if an explicit query context is already specified.
|
||||
ModelQueryTimeout time.Duration
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
// write hooks
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
}
|
||||
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
@@ -81,6 +85,21 @@ func (dao *Dao) Clone() *Dao {
|
||||
return &clone
|
||||
}
|
||||
|
||||
// WithoutHooks returns a new Dao with the same configuration options
|
||||
// as the current one, but without create/update/delete hooks.
|
||||
func (dao *Dao) WithoutHooks() *Dao {
|
||||
new := dao.Clone()
|
||||
|
||||
new.BeforeCreateFunc = nil
|
||||
new.AfterCreateFunc = nil
|
||||
new.BeforeUpdateFunc = nil
|
||||
new.AfterUpdateFunc = nil
|
||||
new.BeforeDeleteFunc = nil
|
||||
new.AfterDeleteFunc = nil
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
@@ -134,56 +153,75 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
txDao := New(tx)
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m)
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m)
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m)
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fn(txDao)
|
||||
})
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
if txError != nil {
|
||||
return txError
|
||||
}
|
||||
|
||||
return txError
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
var errs []error
|
||||
for _, call := range afterCalls {
|
||||
var err error
|
||||
switch call.Action {
|
||||
case "create":
|
||||
err = dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
err = dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
err = dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
// @todo after go 1.20+ upgrade consider replacing with errors.Join()
|
||||
var errsMsg strings.Builder
|
||||
for _, err := range errs {
|
||||
errsMsg.WriteString(err.Error())
|
||||
errsMsg.WriteString("; ")
|
||||
}
|
||||
return fmt.Errorf("after transaction errors: %s", errsMsg.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
@@ -196,21 +234,23 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
}
|
||||
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
action := func() error {
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
return retryDao.BeforeDeleteFunc(retryDao, m, action)
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,35 +281,35 @@ func (dao *Dao) update(m models.Model) error {
|
||||
|
||||
m.RefreshUpdated()
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
return dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
if err := dao.BeforeUpdateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeUpdateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) create(m models.Model) error {
|
||||
@@ -289,36 +329,36 @@ func (dao *Dao) create(m models.Model) error {
|
||||
m.RefreshUpdated()
|
||||
}
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
return dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
if err := dao.BeforeCreateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeCreateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
|
||||
|
||||
+131
-47
@@ -49,33 +49,37 @@ func TestDaoClone(t *testing.T) {
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := dao.Clone()
|
||||
clone.MaxLockRetries = 3
|
||||
clone.ModelQueryTimeout = 4
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["NewAfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.MaxLockRetries == clone.MaxLockRetries {
|
||||
@@ -86,16 +90,18 @@ func TestDaoClone(t *testing.T) {
|
||||
t.Fatal("Expected different ModelQueryTimeout")
|
||||
}
|
||||
|
||||
emptyAction := func() error { return nil }
|
||||
|
||||
// trigger hooks
|
||||
dao.BeforeDeleteFunc(nil, nil)
|
||||
dao.BeforeUpdateFunc(nil, nil)
|
||||
dao.BeforeCreateFunc(nil, nil)
|
||||
dao.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
dao.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
dao.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
dao.AfterDeleteFunc(nil, nil)
|
||||
dao.AfterUpdateFunc(nil, nil)
|
||||
dao.AfterCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil)
|
||||
clone.BeforeUpdateFunc(nil, nil)
|
||||
clone.BeforeCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
clone.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
clone.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
clone.AfterDeleteFunc(nil, nil)
|
||||
clone.AfterUpdateFunc(nil, nil)
|
||||
clone.AfterCreateFunc(nil, nil)
|
||||
@@ -120,6 +126,75 @@ func TestDaoClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoWithoutHooks(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
hookCalls := map[string]int{}
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
new := dao.WithoutHooks()
|
||||
|
||||
if new.MaxLockRetries != dao.MaxLockRetries {
|
||||
t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
|
||||
}
|
||||
|
||||
if new.ModelQueryTimeout != dao.ModelQueryTimeout {
|
||||
t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
|
||||
}
|
||||
|
||||
if new.BeforeDeleteFunc != nil {
|
||||
t.Fatal("Expected BeforeDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeUpdateFunc != nil {
|
||||
t.Fatal("Expected BeforeUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeCreateFunc != nil {
|
||||
t.Fatal("Expected BeforeCreateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterDeleteFunc != nil {
|
||||
t.Fatal("Expected AfterDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterUpdateFunc != nil {
|
||||
t.Fatal("Expected AfterUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterCreateFunc != nil {
|
||||
t.Fatal("Expected AfterCreateFunc to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -415,12 +490,13 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterCreateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
@@ -441,7 +517,7 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -473,12 +549,13 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterUpdateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
@@ -498,7 +575,7 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -524,12 +601,13 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterDeleteHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
@@ -550,7 +628,7 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -577,13 +655,13 @@ func TestDaoBeforeHooksError(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_create")
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_update")
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_delete")
|
||||
}
|
||||
|
||||
@@ -622,27 +700,30 @@ func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
@@ -710,27 +791,30 @@ func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
|
||||
@@ -378,6 +378,12 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
|
||||
}
|
||||
}
|
||||
|
||||
// wrap view query if necessary
|
||||
query, err = txDao.normalizeViewQueryId(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize view query id: %w", err)
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
if err := txDao.SaveView(newCollection.Name, query); err != nil {
|
||||
return err
|
||||
@@ -389,6 +395,52 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
|
||||
})
|
||||
}
|
||||
|
||||
// @todo consider removing once custom id types are supported
|
||||
//
|
||||
// normalizeViewQueryId wraps (if necessary) the provided view query
|
||||
// with a subselect to ensure that the id column is a text since
|
||||
// currently we don't support non-string model ids
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110).
|
||||
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
|
||||
parsed, err := dao.parseQueryToFields(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
needWrapping := true
|
||||
|
||||
idField := parsed[schema.FieldNameId]
|
||||
if idField != nil && idField.field != nil &&
|
||||
idField.field.Type != schema.FieldTypeJson &&
|
||||
idField.field.Type != schema.FieldTypeNumber &&
|
||||
idField.field.Type != schema.FieldTypeBool {
|
||||
needWrapping = false
|
||||
}
|
||||
|
||||
if !needWrapping {
|
||||
return query, nil // no changes needed
|
||||
}
|
||||
|
||||
// raw parse to preserve the columns order
|
||||
rawParsed := new(identifiersParser)
|
||||
if err := rawParsed.parse(query); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(rawParsed.columns))
|
||||
for _, col := range rawParsed.columns {
|
||||
if col.alias == schema.FieldNameId {
|
||||
columns = append(columns, fmt.Sprintf("cast(%s as text) %s", col.alias, col.alias))
|
||||
} else {
|
||||
columns = append(columns, col.alias)
|
||||
}
|
||||
}
|
||||
|
||||
query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
|
||||
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
|
||||
+113
-3
@@ -210,11 +210,12 @@ func TestDeleteCollection(t *testing.T) {
|
||||
{colUnsaved, true},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{colBase, true}, // depend on view1, view2 and view2
|
||||
{colView1, true}, // view2 depend on it
|
||||
{colView2, false},
|
||||
{colView1, false}, // no longer has dependent collections
|
||||
{colBase, false},
|
||||
{colAuth, false}, // should delete also its related external auths
|
||||
{colBase, false}, // no longer has dependent views
|
||||
{colAuth, false}, // should delete also its related external auths
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -393,8 +394,117 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCollectionViewWrapping(t *testing.T) {
|
||||
viewName := "test_wrapping"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"no wrapping - text field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - id field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - relation field",
|
||||
"select rel_one as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - select field",
|
||||
"select select_many as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - email field",
|
||||
"select email as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - datetime field",
|
||||
"select datetime as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - url field",
|
||||
"select url as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field",
|
||||
"select bool as id, text as txt, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(id as text) id,txt,url FROM (select bool as id, text as txt, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field (different order)",
|
||||
"select text as txt, url, bool as id from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT txt,url,cast(id as text) id FROM (select text as txt, url, bool as id from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - json field",
|
||||
"select json as id, text, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(id as text) id,text,url FROM (select json as id, text, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
"select 1 as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(id as text) id FROM (select 1 as id))",
|
||||
},
|
||||
{
|
||||
"wrapping - expresion",
|
||||
"select ('test') as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(id as text) id FROM (select ('test') as id))",
|
||||
},
|
||||
{
|
||||
"no wrapping - cast as text",
|
||||
"select cast('test' as text) as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: viewName,
|
||||
Type: models.CollectionTypeView,
|
||||
Options: types.JsonMap{
|
||||
"query": s.query,
|
||||
},
|
||||
}
|
||||
|
||||
err := app.Dao().SaveCollection(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sql string
|
||||
|
||||
rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
Bind(dbx.Params{"name": viewName}).
|
||||
Row(&sql)
|
||||
if rowErr != nil {
|
||||
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
|
||||
}
|
||||
|
||||
if sql != s.expected {
|
||||
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCollections(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
+252
-73
@@ -1,6 +1,8 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -8,86 +10,123 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
tableName := collection.Name
|
||||
// RecordQuery returns a new Record select query from a collection model, id or name.
|
||||
//
|
||||
// In case a collection id or name is provided and that collection doesn't
|
||||
// actually exists, the generated query will be created with a cancelled context
|
||||
// and will fail once an executor (Row(), One(), All(), etc.) is called.
|
||||
func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
|
||||
var tableName string
|
||||
var collection *models.Collection
|
||||
var collectionErr error
|
||||
switch c := collectionModelOrIdentifier.(type) {
|
||||
case *models.Collection:
|
||||
collection = c
|
||||
tableName = collection.Name
|
||||
case models.Collection:
|
||||
collection = &c
|
||||
tableName = collection.Name
|
||||
case string:
|
||||
collection, collectionErr = dao.FindCollectionByNameOrId(c)
|
||||
if collection != nil {
|
||||
tableName = collection.Name
|
||||
} else {
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__missing_" + c
|
||||
}
|
||||
default:
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__invalidCollectionModelOrIdentifier"
|
||||
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
|
||||
}
|
||||
|
||||
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
|
||||
|
||||
return dao.DB().
|
||||
Select(selectCols).
|
||||
From(tableName).
|
||||
WithBuildHook(func(query *dbx.Query) {
|
||||
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
query := dao.DB().Select(selectCols).From(tableName)
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
// in case of an error attach a new context and cancel it immediately with the error
|
||||
if collectionErr != nil {
|
||||
// @todo consider changing to WithCancelCause when upgrading
|
||||
// the min Go requirement to 1.20, so that we can pass the error
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
query.WithContext(ctx)
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
default:
|
||||
return query.WithBuildHook(func(q *dbx.Query) {
|
||||
q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = records
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
default:
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
*v = records
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
@@ -158,6 +197,8 @@ func (dao *Dao) FindRecordsByIds(
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
|
||||
//
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
//
|
||||
// Returns all collection records if no expressions are provided.
|
||||
@@ -170,12 +211,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
query := dao.RecordQuery(collectionNameOrId)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
@@ -200,14 +236,9 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
key string,
|
||||
value any,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
err := dao.RecordQuery(collectionNameOrId).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
@@ -218,6 +249,98 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindRecordsByFilter returns limit number of records matching the
|
||||
// provided string filter.
|
||||
//
|
||||
// The sort argument is optional and can be empty string OR the same format
|
||||
// used in the web APIs, eg. "-created,title".
|
||||
//
|
||||
// If the limit argument is <= 0, no limit is applied to the query and
|
||||
// all matching records are returned.
|
||||
//
|
||||
// NB! Don't put untrusted user input in the filter string as it
|
||||
// practically would allow the users to inject their own custom filter.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindRecordsByFilter("posts", "title ~ 'lorem ipsum' && visible = true", "-created", 10)
|
||||
func (dao *Dao) FindRecordsByFilter(
|
||||
collectionNameOrId string,
|
||||
filter string,
|
||||
sort string,
|
||||
limit int,
|
||||
) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := dao.RecordQuery(collection)
|
||||
|
||||
// build a fields resolver and attach the generated conditions to the query
|
||||
// ---
|
||||
resolver := resolvers.NewRecordFieldResolver(
|
||||
dao,
|
||||
collection, // the base collection
|
||||
nil, // no request data
|
||||
true, // allow searching hidden/protected fields like "email"
|
||||
)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver)
|
||||
if err != nil || expr == nil {
|
||||
return nil, errors.New("invalid or empty filter expression")
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
if sort != "" {
|
||||
for _, sortField := range search.ParseSortFromString(sort) {
|
||||
expr, err := sortField.BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != "" {
|
||||
q.AndOrderBy(expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
|
||||
// ---
|
||||
|
||||
if limit > 0 {
|
||||
q.Limit(int64(limit))
|
||||
}
|
||||
|
||||
records := []*models.Record{}
|
||||
|
||||
if err := q.All(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// FindFirstRecordByFilter returns the first available record matching the provided filter.
|
||||
//
|
||||
// NB! Don't put untrusted user input in the filter string as it
|
||||
// practically would allow the users to inject their own custom filter.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindFirstRecordByFilter("posts", "slug='test'")
|
||||
func (dao *Dao) FindFirstRecordByFilter(collectionNameOrId string, filter string) (*models.Record, error) {
|
||||
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return result[0], nil
|
||||
}
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
@@ -386,6 +509,62 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
return username
|
||||
}
|
||||
|
||||
// CanAccessRecord checks if a record is allowed to be accessed by the
|
||||
// specified requestInfo and accessRule.
|
||||
//
|
||||
// Rule and db checks are ignored in case requestInfo.Admin is set.
|
||||
//
|
||||
// The returned error indicate that something unexpected happened during
|
||||
// the check (eg. invalid rule or db error).
|
||||
//
|
||||
// The method always return false on invalid access rule or db error.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// requestInfo := apis.RequestInfo(c /* echo.Context */)
|
||||
// record, _ := dao.FindRecordById("example", "RECORD_ID")
|
||||
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
|
||||
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
|
||||
//
|
||||
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
|
||||
func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
|
||||
if requestInfo.Admin != nil {
|
||||
// admins can access everything
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if *accessRule == "" {
|
||||
// empty public rule, aka. everyone can access
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
query := dao.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
|
||||
|
||||
// parse and apply the access rule filter
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// SaveRecord persists the provided Record model in the database.
|
||||
//
|
||||
// If record.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
|
||||
+15
-7
@@ -1,7 +1,6 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -24,21 +23,27 @@ type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]
|
||||
|
||||
// ExpandRecord expands the relations of a single Record model.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
|
||||
}
|
||||
|
||||
// ExpandRecords expands the relations of the provided Record models list.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
normalized := normalizeExpands(expands)
|
||||
|
||||
failed := map[string]error{}
|
||||
|
||||
for _, expand := range normalized {
|
||||
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
|
||||
if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
|
||||
failed[expand] = err
|
||||
}
|
||||
}
|
||||
@@ -49,13 +54,16 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
// notes:
|
||||
// - fetchFunc must be non-nil func
|
||||
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
|
||||
// - all records are expected to be from the same collection
|
||||
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
|
||||
// - indirect expands are supported only with single relation fields
|
||||
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
|
||||
if fetchFunc == nil {
|
||||
return errors.New("Relation records fetchFunc is not set.")
|
||||
// load a default fetchFunc
|
||||
fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
return dao.FindRecordsByIds(relCollection.Id, relIds)
|
||||
}
|
||||
}
|
||||
|
||||
if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
|
||||
|
||||
+26
-18
@@ -48,15 +48,6 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
@@ -101,6 +92,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
1,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
@@ -132,6 +136,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
@@ -228,15 +245,6 @@ func TestExpandRecord(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
|
||||
+64
-27
@@ -173,10 +173,23 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// temporary disable the schema error checks to prevent view and trigger errors
|
||||
// when "altering" (aka. deleting and recreating) the non-normalized columns
|
||||
if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
// executed with defer to make sure that the pragma is always reverted
|
||||
// in case of an error and when nested transactions are used
|
||||
defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
for _, newField := range newCollection.Schema.Fields() {
|
||||
oldField := oldCollection.Schema.GetFieldById(newField.Id)
|
||||
if oldField == nil {
|
||||
continue
|
||||
// allow to continue even if there is no old field for the cases
|
||||
// when a new field is added and there are already inserted data
|
||||
var isOldMultiple bool
|
||||
if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
}
|
||||
|
||||
var isNewMultiple bool
|
||||
@@ -184,20 +197,30 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
isNewMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
var isOldMultiple bool
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
if isOldMultiple == isNewMultiple {
|
||||
continue // no change
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
// update the column definition by:
|
||||
// 1. inserting a new column with the new definition
|
||||
// 2. copy normalized values from the original column to the new one
|
||||
// 3. drop the original column
|
||||
// 4. rename the new column to the original column
|
||||
// -------------------------------------------------------
|
||||
|
||||
originalName := newField.Name
|
||||
tempName := "_" + newField.Name + security.PseudorandomString(5)
|
||||
|
||||
_, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var copyQuery *dbx.Query
|
||||
|
||||
if !isOldMultiple && isNewMultiple {
|
||||
// single -> multiple (convert to array)
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
@@ -212,19 +235,19 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
} else {
|
||||
// multiple -> single (keep only the last element)
|
||||
//
|
||||
// note: for file fields the actual files are not deleted
|
||||
// allowing additional custom handling via migration.
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
// note: for file fields the actual file objects are not
|
||||
// deleted allowing additional custom handling via migration
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
@@ -239,21 +262,35 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
// copy the normalized values
|
||||
if _, err := copyQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop the original column
|
||||
if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the new column back to the original
|
||||
if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// revert the pragma and reload the schema
|
||||
_, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
return revertErr
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+117
-57
@@ -171,89 +171,149 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
opt := relManyField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
{
|
||||
// new multivaluer field to check whether the array normalization
|
||||
// will be applied for already inserted data
|
||||
collection.Schema.AddField(&schema.SchemaField{
|
||||
Name: "new_multiple",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
Values: []string{"a", "b", "c"},
|
||||
MaxSelect: 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type expectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
// ensures that the writable schema was reverted to its expected default
|
||||
var writableSchema bool
|
||||
app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
|
||||
if writableSchema == true {
|
||||
t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
// check whether the columns DEFAULT definition was updated
|
||||
// ---------------------------------------------------------------
|
||||
tableInfo, err := app.Dao().TableInfo(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tableInfoExpectations := map[string]string{
|
||||
"select_one": `'[]'`,
|
||||
"select_many": `''`,
|
||||
"file_one": `'[]'`,
|
||||
"file_many": `''`,
|
||||
"rel_one": `'[]'`,
|
||||
"rel_many": `''`,
|
||||
"new_multiple": `'[]'`,
|
||||
}
|
||||
for col, dflt := range tableInfoExpectations {
|
||||
t.Run("check default for "+col, func(t *testing.T) {
|
||||
var row *models.TableInfoRow
|
||||
for _, r := range tableInfo {
|
||||
if r.Name == col {
|
||||
row = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatalf("Missing info for column %q", col)
|
||||
}
|
||||
|
||||
if v := row.DefaultValue.String(); v != dflt {
|
||||
t.Fatalf("Expected default value %q, got %q", dflt, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// check whether the values were normalized
|
||||
// ---------------------------------------------------------------
|
||||
type fieldsExpectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
NewMultiple string `db:"new_multiple"`
|
||||
}
|
||||
|
||||
fieldsScenarios := []struct {
|
||||
recordId string
|
||||
expected expectation
|
||||
expected fieldsExpectation
|
||||
}{
|
||||
{
|
||||
"imy661ixudk5izi",
|
||||
expectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
fieldsExpectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"al1h9ijdeojtsjy",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"84nmscqy84lsi1t",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := new(expectation)
|
||||
for _, s := range fieldsScenarios {
|
||||
t.Run("check fields for record "+s.recordId, func(t *testing.T) {
|
||||
result := new(fieldsExpectation)
|
||||
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
"new_multiple",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load record: %v", err)
|
||||
}
|
||||
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode result: %v", err)
|
||||
}
|
||||
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode expectation: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
|
||||
}
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+371
-6
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -28,11 +27,33 @@ func TestRecordQuery(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
scenarios := []struct {
|
||||
name any
|
||||
collection any
|
||||
expectedTotal int
|
||||
expectError bool
|
||||
}{
|
||||
{"with nil value", nil, 0, true},
|
||||
{"with invalid or missing collection id/name", "missing", 0, true},
|
||||
{"with pointer model", collection, 3, false},
|
||||
{"with value model", *collection, 3, false},
|
||||
{"with name", "demo1", 3, false},
|
||||
{"with id", "wsmn24bux7wo113", 3, false},
|
||||
}
|
||||
|
||||
sql := app.Dao().RecordQuery(collection).Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
for _, s := range scenarios {
|
||||
var records []*models.Record
|
||||
err := app.Dao().RecordQuery(s.collection).All(&records)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if total := len(records); total != s.expectedTotal {
|
||||
t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +426,350 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordsByFilter(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
sort string
|
||||
limit int
|
||||
expectError bool
|
||||
expectRecordIds []string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
"",
|
||||
0,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"simple filter",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
},
|
||||
{
|
||||
"multi-condition filter with sort",
|
||||
"demo2",
|
||||
"id != '' && active=true",
|
||||
"-created,title",
|
||||
-1, // should behave the same as 0
|
||||
false,
|
||||
[]string{
|
||||
"0yxhwia2amd8gec",
|
||||
"achvryl401bhse3",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with limit",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"title",
|
||||
2,
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
"achvryl401bhse3",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByFilter(
|
||||
s.collectionIdOrName,
|
||||
s.filter,
|
||||
s.sort,
|
||||
s.limit,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(records) != len(s.expectRecordIds) {
|
||||
t.Errorf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
|
||||
continue
|
||||
}
|
||||
|
||||
for i, id := range s.expectRecordIds {
|
||||
if id != records[i].Id {
|
||||
t.Errorf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByFilter(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
expectError bool
|
||||
expectRecordId string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter but no matches",
|
||||
"demo2",
|
||||
"id = 'test'",
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter and multiple matches",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
false,
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Id != s.expectRecordId {
|
||||
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAccessRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
requestInfo *models.RequestInfo
|
||||
rule *string
|
||||
expected bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"as admin with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with non-empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id = ''"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as guest with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("@request.auth.id != ''"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as auth record with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 2},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
|
||||
|
||||
if result != s.expected {
|
||||
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecordValueUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+36
-9
@@ -235,6 +235,8 @@ func defaultViewField(name string) *schema.SchemaField {
|
||||
}
|
||||
}
|
||||
|
||||
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
|
||||
|
||||
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
|
||||
p := new(identifiersParser)
|
||||
if err := p.parse(selectQuery); err != nil {
|
||||
@@ -257,15 +259,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
for _, col := range p.columns {
|
||||
colLower := strings.ToLower(col.original)
|
||||
|
||||
// numeric expression cast
|
||||
if strings.Contains(colLower, "(") &&
|
||||
(strings.HasPrefix(colLower, "count(") ||
|
||||
strings.HasPrefix(colLower, "total(") ||
|
||||
strings.Contains(colLower, " as numeric") ||
|
||||
strings.Contains(colLower, " as real") ||
|
||||
strings.Contains(colLower, " as int") ||
|
||||
strings.Contains(colLower, " as integer") ||
|
||||
strings.Contains(colLower, " as decimal")) {
|
||||
// numeric aggregations
|
||||
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
@@ -275,6 +270,38 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
continue
|
||||
}
|
||||
|
||||
castMatch := castRegex.FindStringSubmatch(colLower)
|
||||
|
||||
// numeric casts
|
||||
if len(castMatch) == 2 {
|
||||
switch castMatch[1] {
|
||||
case "real", "integer", "int", "decimal", "numeric":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "text":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "boolean", "bool":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(col.original, ".")
|
||||
|
||||
var fieldName string
|
||||
|
||||
+7
-1
@@ -330,7 +330,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with numeric casts",
|
||||
"query with casts",
|
||||
`select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
@@ -339,6 +339,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric,
|
||||
cast(a.id as text) cast_text,
|
||||
cast(a.id as bool) cast_bool,
|
||||
cast(a.id as boolean) cast_boolean,
|
||||
avg(a.id) avg,
|
||||
sum(a.id) sum,
|
||||
total(a.id) total,
|
||||
@@ -354,6 +357,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"cast_real": schema.FieldTypeNumber,
|
||||
"cast_decimal": schema.FieldTypeNumber,
|
||||
"cast_numeric": schema.FieldTypeNumber,
|
||||
"cast_text": schema.FieldTypeText,
|
||||
"cast_bool": schema.FieldTypeBool,
|
||||
"cast_boolean": schema.FieldTypeBool,
|
||||
// json because they are nullable
|
||||
"sum": schema.FieldTypeJson,
|
||||
"avg": schema.FieldTypeJson,
|
||||
|
||||
+33
-5
@@ -22,6 +22,30 @@ func main() {
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var hooksDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&hooksDir,
|
||||
"hooksDir",
|
||||
"",
|
||||
"the directory with the JS app hooks",
|
||||
)
|
||||
|
||||
var hooksWatch bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&hooksWatch,
|
||||
"hooksWatch",
|
||||
true,
|
||||
"auto restart the app on pb_hooks file change",
|
||||
)
|
||||
|
||||
var hooksPool int
|
||||
app.RootCmd.PersistentFlags().IntVar(
|
||||
&hooksPool,
|
||||
"hooksPool",
|
||||
50,
|
||||
"the total prewarm goja.Runtime instances for the JS app hooks execution",
|
||||
)
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&migrationsDir,
|
||||
@@ -68,20 +92,23 @@ func main() {
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
// load jsvm (hooks and migrations)
|
||||
jsvm.MustRegister(app, jsvm.Config{
|
||||
MigrationsDir: migrationsDir,
|
||||
HooksDir: hooksDir,
|
||||
HooksWatch: hooksWatch,
|
||||
HooksPoolSize: hooksPool,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// GitHub selfupdate
|
||||
ghupdate.MustRegister(app, app.RootCmd, nil)
|
||||
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
@@ -105,5 +132,6 @@ func defaultPublicDir() string {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
@@ -268,10 +268,10 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
if relCollection == nil || relCollection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
@@ -282,14 +282,27 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
// allow only views to have relations to other views
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3000)
|
||||
if form.Type != models.CollectionTypeView && relCollection.IsView() {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_non_view_base_relation_collection",
|
||||
"Non view collections are not allowed to have a view relation.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
if relCollection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, relCollection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
|
||||
+170
-127
@@ -400,6 +400,54 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
|
||||
// view tests
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
"base->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options":{
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"auth->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options": {
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"view->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select view1.id, view1.id as rel from view1"
|
||||
}
|
||||
}`,
|
||||
[]string{}, // allowed
|
||||
},
|
||||
{
|
||||
"view create failure",
|
||||
"",
|
||||
@@ -495,137 +543,132 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf(
|
||||
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
|
||||
continue
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Fatalf("Expected to find collection %q, got nil", form.Name)
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Fatalf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestCollectionsImportValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmit(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
+38
-30
@@ -117,7 +117,7 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestData(
|
||||
func (form *RecordUpsert) extractRequestInfo(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
@@ -219,12 +219,12 @@ func (form *RecordUpsert) extractMultipartFormData(
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
|
||||
requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := form.LoadData(requestData); err != nil {
|
||||
if err := form.LoadData(requestInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -349,39 +349,39 @@ func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided regular record data fields into the form.
|
||||
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
|
||||
// load base system fields
|
||||
if v, ok := requestData[schema.FieldNameId]; ok {
|
||||
if v, ok := requestInfo[schema.FieldNameId]; ok {
|
||||
form.Id = cast.ToString(v)
|
||||
}
|
||||
|
||||
// load auth system fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
if v, ok := requestData[schema.FieldNameUsername]; ok {
|
||||
if v, ok := requestInfo[schema.FieldNameUsername]; ok {
|
||||
form.Username = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData[schema.FieldNameEmail]; ok {
|
||||
if v, ok := requestInfo[schema.FieldNameEmail]; ok {
|
||||
form.Email = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
|
||||
if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok {
|
||||
form.EmailVisibility = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData[schema.FieldNameVerified]; ok {
|
||||
if v, ok := requestInfo[schema.FieldNameVerified]; ok {
|
||||
form.Verified = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["password"]; ok {
|
||||
if v, ok := requestInfo["password"]; ok {
|
||||
form.Password = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["passwordConfirm"]; ok {
|
||||
if v, ok := requestInfo["passwordConfirm"]; ok {
|
||||
form.PasswordConfirm = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["oldPassword"]; ok {
|
||||
if v, ok := requestInfo["oldPassword"]; ok {
|
||||
form.OldPassword = cast.ToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// replace modifiers (if any)
|
||||
requestData = form.record.ReplaceModifers(requestData)
|
||||
requestInfo = form.record.ReplaceModifers(requestInfo)
|
||||
|
||||
// create a shallow copy of form.data
|
||||
var extendedData = make(map[string]any, len(form.data))
|
||||
@@ -390,7 +390,7 @@ func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
}
|
||||
|
||||
// extend form.data with the request data
|
||||
rawData, err := json.Marshal(requestData)
|
||||
rawData, err := json.Marshal(requestInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -744,36 +744,44 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
|
||||
// upload new files (if any)
|
||||
//
|
||||
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
|
||||
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
|
||||
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
|
||||
// but BEFORE the actual record db persistence
|
||||
// ---
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if form.dao.BeforeCreateFunc != nil {
|
||||
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
newAction := func() error {
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return action()
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
if form.dao.BeforeCreateFunc != nil {
|
||||
return form.dao.BeforeCreateFunc(eventDao, m, newAction)
|
||||
}
|
||||
|
||||
return nil
|
||||
return newAction()
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if form.dao.BeforeUpdateFunc != nil {
|
||||
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
newAction := func() error {
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return action()
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
if form.dao.BeforeUpdateFunc != nil {
|
||||
return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
|
||||
}
|
||||
|
||||
return nil
|
||||
return newAction()
|
||||
}
|
||||
// ---
|
||||
|
||||
@@ -896,7 +904,7 @@ func (form *RecordUpsert) prepareError(err error) error {
|
||||
c := form.record.Collection()
|
||||
for _, f := range c.Schema.Fields() {
|
||||
// blank space to unify multi-columns lookup
|
||||
if strings.Contains(msg+" ", fmt.Sprintf("%s.%s ", strings.ToLower(c.Name), f.Name)) {
|
||||
if strings.Contains(msg+" ", strings.ToLower(c.Name+"."+f.Name)) {
|
||||
validationErrs[f.Name] = validation.NewError("validation_not_unique", "Value must be unique")
|
||||
}
|
||||
}
|
||||
|
||||
+119
-8
@@ -12,14 +12,17 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
|
||||
@@ -712,8 +715,11 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
name string
|
||||
existingId string
|
||||
data map[string]any
|
||||
manageAccess bool
|
||||
@@ -907,9 +913,6 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -920,7 +923,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
var err error
|
||||
record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId)
|
||||
t.Errorf("[%s] Failed to fetch auth record with id %s", s.name, s.existingId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -928,7 +931,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.SetFullManageAccess(s.manageAccess)
|
||||
if err := form.LoadData(s.data); err != nil {
|
||||
t.Errorf("[%s] Failed to load form data", s.testName)
|
||||
t.Errorf("[%s] Failed to load form data", s.name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -936,11 +939,119 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
|
||||
hasErr := submitErr != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr)
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && record.Username() == "" {
|
||||
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record)
|
||||
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.name, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertUniqueValidator(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create a dummy collection
|
||||
collection := &models.Collection{
|
||||
Name: "test",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Type: "text",
|
||||
Name: "fieldA",
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Type: "text",
|
||||
Name: "fieldB",
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Type: "text",
|
||||
Name: "fieldC",
|
||||
},
|
||||
),
|
||||
Indexes: types.JsonArray[string]{
|
||||
// the field case shouldn't matter
|
||||
"create unique index unique_single_idx on test (fielda)",
|
||||
"create unique index unique_combined_idx on test (fieldb, FIELDC)",
|
||||
},
|
||||
}
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dummyRecord := models.NewRecord(collection)
|
||||
dummyRecord.Set("fieldA", "a")
|
||||
dummyRecord.Set("fieldB", "b")
|
||||
dummyRecord.Set("fieldC", "c")
|
||||
if err := app.Dao().SaveRecord(dummyRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"duplicated unique value",
|
||||
map[string]any{
|
||||
"fieldA": "a",
|
||||
},
|
||||
[]string{"fieldA"},
|
||||
},
|
||||
{
|
||||
"duplicated combined unique value",
|
||||
map[string]any{
|
||||
"fieldB": "b",
|
||||
"fieldC": "c",
|
||||
},
|
||||
[]string{"fieldB", "fieldC"},
|
||||
},
|
||||
{
|
||||
"non-duplicated unique value",
|
||||
map[string]any{
|
||||
"fieldA": "a2",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"non-duplicated combined unique value",
|
||||
map[string]any{
|
||||
"fieldB": "b",
|
||||
"fieldC": "d",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
record := models.NewRecord(collection)
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
if err := form.LoadData(s.data); err != nil {
|
||||
t.Errorf("[%s] Failed to load form data", s.name)
|
||||
continue
|
||||
}
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
|
||||
continue
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
|
||||
return runInterceptors(form.Settings, func(s *settings.Settings) error {
|
||||
form.Settings = s
|
||||
|
||||
oldSettings, err := form.app.Settings().Clone();
|
||||
oldSettings, err := form.app.Settings().Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,58 +4,61 @@ go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/aws/aws-sdk-go v1.44.289
|
||||
github.com/aws/aws-sdk-go v1.44.318
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.0
|
||||
github.com/dop251/goja v0.0.0-20230427124612-428fc442ff5f
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230731184339-9ba67d720a81
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/pocketbase/dbx v1.10.0
|
||||
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1
|
||||
github.com/spf13/cast v1.5.1
|
||||
github.com/spf13/cobra v1.7.0
|
||||
gocloud.dev v0.30.0
|
||||
golang.org/x/crypto v0.10.0
|
||||
golang.org/x/net v0.11.0
|
||||
golang.org/x/oauth2 v0.9.0
|
||||
modernc.org/sqlite v1.23.1
|
||||
gocloud.dev v0.32.0
|
||||
golang.org/x/crypto v0.12.0
|
||||
golang.org/x/net v0.14.0
|
||||
golang.org/x/oauth2 v0.11.0
|
||||
golang.org/x/sync v0.3.0
|
||||
modernc.org/sqlite v1.25.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.18.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.70 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.35.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.20.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.77 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 // indirect
|
||||
github.com/aws/smithy-go v1.14.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230510103437-eeec1cb781c3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.11.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
@@ -67,24 +70,24 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/image v0.8.0 // indirect
|
||||
golang.org/x/mod v0.11.0 // indirect
|
||||
golang.org/x/sys v0.9.0 // indirect
|
||||
golang.org/x/term v0.9.0 // indirect
|
||||
golang.org/x/text v0.10.0 // indirect
|
||||
golang.org/x/image v0.11.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
golang.org/x/term v0.11.0 // indirect
|
||||
golang.org/x/text v0.12.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.10.0 // indirect
|
||||
golang.org/x/tools v0.12.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.128.0 // indirect
|
||||
google.golang.org/api v0.135.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect
|
||||
google.golang.org/grpc v1.56.1 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect
|
||||
google.golang.org/grpc v1.57.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.14 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.6.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
|
||||
+5
-10
@@ -2,7 +2,6 @@ package mails
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -67,15 +66,11 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterAdminResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterAdminResetPasswordSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
+15
-28
@@ -2,7 +2,6 @@ package mails
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -44,17 +43,13 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
event.Record = authRecord
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordResetPasswordSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
// SendRecordVerification sends a verification request email to the specified user.
|
||||
@@ -88,17 +83,13 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
event.Record = authRecord
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordVerificationSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordVerificationSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
// SendUserChangeEmail sends a change email confirmation email to the specified user.
|
||||
@@ -135,17 +126,13 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
"newEmail": newEmail,
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordChangeEmailSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
func resolveEmailTemplate(
|
||||
|
||||
@@ -12,93 +12,97 @@ import (
|
||||
// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.IsView() {
|
||||
// skip view collections
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range c.Schema.Fields() {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if opt.IsMultiple() {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
} else {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trigger view query update after the records normalization
|
||||
// (ignore save error in case of invalid query to allow users to change it from the UI)
|
||||
for _, c := range collections {
|
||||
if !c.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
dao.SaveCollection(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
return normalizeMultivaluerFields(db)
|
||||
}, func(db dbx.Builder) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeMultivaluerFields(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.IsView() {
|
||||
// skip view collections
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range c.Schema.Fields() {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if opt.IsMultiple() {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
} else {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trigger view query update after the records normalization
|
||||
// (ignore save error in case of invalid query to allow users to change it from the UI)
|
||||
for _, c := range collections {
|
||||
if !c.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
dao.SaveCollection(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// Renormalizes old single and multiple values of MultiValuer fields (file, select, relation)
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/2930).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
return normalizeMultivaluerFields(db)
|
||||
}, func(db dbx.Builder) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Reset all previously inserted NULL values to the fields zero-default.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
if collection.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
defaultVal := "''"
|
||||
|
||||
switch f.Type {
|
||||
case schema.FieldTypeJson:
|
||||
continue
|
||||
case schema.FieldTypeBool:
|
||||
defaultVal = "FALSE"
|
||||
case schema.FieldTypeNumber:
|
||||
defaultVal = "0"
|
||||
default:
|
||||
if opt, ok := f.Options.(schema.MultiValuer); ok && opt.IsMultiple() {
|
||||
defaultVal = "'[]'"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.NewQuery(fmt.Sprintf(
|
||||
"UPDATE {{%s}} SET [[%s]] = %s WHERE [[%s]] IS NULL",
|
||||
collection.Name,
|
||||
f.Name,
|
||||
defaultVal,
|
||||
f.Name,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Transform the relation fields to views from non-view collections to json or text fields
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3000).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
views, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, view := range views {
|
||||
refs, err := dao.FindCollectionReferences(view)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for collection, fields := range refs {
|
||||
if collection.IsView() {
|
||||
continue // view-view relations are allowed
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if opt.IsMultiple() {
|
||||
f.Type = schema.FieldTypeJson
|
||||
f.Options = &schema.JsonOptions{}
|
||||
} else {
|
||||
f.Type = schema.FieldTypeText
|
||||
f.Options = &schema.TextOptions{}
|
||||
}
|
||||
|
||||
// replace the existing field
|
||||
// (this usually is not necessary since it is a pointer,
|
||||
// but it is better to be explicit in case FindCollectionReferences changes)
|
||||
collection.Schema.AddField(f)
|
||||
}
|
||||
|
||||
// "raw" save without records table sync
|
||||
if err := dao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// Resave all view collections to ensure that the proper id normalization is applied.
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110)
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
// ignore errors to allow users to adjust
|
||||
// the view queries after app start
|
||||
dao.SaveCollection(collection)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
+72
-5
@@ -324,7 +324,7 @@ func (m *Record) Set(key string, value any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a single record model data value for "key".
|
||||
// Get returns a normalized single record model data value for "key".
|
||||
func (m *Record) Get(key string) any {
|
||||
switch key {
|
||||
case schema.FieldNameId:
|
||||
@@ -334,11 +334,27 @@ func (m *Record) Get(key string) any {
|
||||
case schema.FieldNameUpdated:
|
||||
return m.Updated
|
||||
default:
|
||||
if m.data == nil {
|
||||
return nil
|
||||
var v any
|
||||
if m.data != nil {
|
||||
v = m.data.Get(key)
|
||||
}
|
||||
|
||||
return m.data.Get(key)
|
||||
// normalize the field value in case it is missing or an incorrect type was set
|
||||
// to ensure that the DB will always have normalized columns value.
|
||||
if field := m.Collection().Schema.GetFieldByName(key); field != nil {
|
||||
v = field.PrepareValue(v)
|
||||
} else if m.collection.IsAuth() {
|
||||
switch key {
|
||||
case schema.FieldNameEmailVisibility, schema.FieldNameVerified:
|
||||
v = cast.ToBool(v)
|
||||
case schema.FieldNameLastResetSentAt, schema.FieldNameLastVerificationSentAt:
|
||||
v, _ = types.ParseDateTime(v)
|
||||
case schema.FieldNameUsername, schema.FieldNameEmail, schema.FieldNameTokenKey, schema.FieldNamePasswordHash:
|
||||
v = cast.ToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +394,56 @@ func (m *Record) GetStringSlice(key string) []string {
|
||||
return list.ToUniqueStringSlice(m.Get(key))
|
||||
}
|
||||
|
||||
// ExpandedOne retrieves a single relation Record from the already
|
||||
// loaded expand data of the current model.
|
||||
//
|
||||
// If the requested expand relation is multiple, this method returns
|
||||
// only first available Record from the expanded relation.
|
||||
//
|
||||
// Returns nil if there is no such expand relation loaded.
|
||||
func (m *Record) ExpandedOne(relField string) *Record {
|
||||
if m.expand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel := m.expand.Get(relField)
|
||||
|
||||
switch v := rel.(type) {
|
||||
case *Record:
|
||||
return v
|
||||
case []*Record:
|
||||
if len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpandedAll retrieves a slice of relation Records from the already
|
||||
// loaded expand data of the current model.
|
||||
//
|
||||
// If the requested expand relation is single, this method normalizes
|
||||
// the return result and will wrap the single model as a slice.
|
||||
//
|
||||
// Returns nil slice if there is no such expand relation loaded.
|
||||
func (m *Record) ExpandedAll(relField string) []*Record {
|
||||
if m.expand == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel := m.expand.Get(relField)
|
||||
|
||||
switch v := rel.(type) {
|
||||
case *Record:
|
||||
return []*Record{v}
|
||||
case []*Record:
|
||||
return v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retrieves the "key" json field value and unmarshals it into "result".
|
||||
//
|
||||
// Example
|
||||
@@ -447,7 +513,8 @@ func (m *Record) ColumnValueMap() map[string]any {
|
||||
|
||||
// PublicExport exports only the record fields that are safe to be public.
|
||||
//
|
||||
// Fields marked as hidden will be exported only if `m.IgnoreEmailVisibility(true)` is set.
|
||||
// For auth records, to force the export of the email field you need to set
|
||||
// `m.IgnoreEmailVisibility(true)`.
|
||||
func (m *Record) PublicExport() map[string]any {
|
||||
result := make(map[string]any, len(m.collection.Schema.Fields())+5)
|
||||
|
||||
|
||||
+104
-12
@@ -541,6 +541,70 @@ func TestRecordMergeExpandNilCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordExpandedRel(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
|
||||
main := models.NewRecord(collection)
|
||||
|
||||
single := models.NewRecord(collection)
|
||||
single.Id = "single"
|
||||
|
||||
multiple1 := models.NewRecord(collection)
|
||||
multiple1.Id = "multiple1"
|
||||
|
||||
multiple2 := models.NewRecord(collection)
|
||||
multiple2.Id = "multiple2"
|
||||
|
||||
main.SetExpand(map[string]any{
|
||||
"single": single,
|
||||
"multiple": []*models.Record{multiple1, multiple2},
|
||||
})
|
||||
|
||||
if v := main.ExpandedOne("missing"); v != nil {
|
||||
t.Fatalf("Expected nil, got %v", v)
|
||||
}
|
||||
|
||||
if v := main.ExpandedOne("single"); v == nil || v.Id != "single" {
|
||||
t.Fatalf("Expected record with id %q, got %v", "single", v)
|
||||
}
|
||||
|
||||
if v := main.ExpandedOne("multiple"); v == nil || v.Id != "multiple1" {
|
||||
t.Fatalf("Expected record with id %q, got %v", "multiple1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordExpandedAll(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
|
||||
main := models.NewRecord(collection)
|
||||
|
||||
single := models.NewRecord(collection)
|
||||
single.Id = "single"
|
||||
|
||||
multiple1 := models.NewRecord(collection)
|
||||
multiple1.Id = "multiple1"
|
||||
|
||||
multiple2 := models.NewRecord(collection)
|
||||
multiple2.Id = "multiple2"
|
||||
|
||||
main.SetExpand(map[string]any{
|
||||
"single": single,
|
||||
"multiple": []*models.Record{multiple1, multiple2},
|
||||
})
|
||||
|
||||
if v := main.ExpandedAll("missing"); v != nil {
|
||||
t.Fatalf("Expected nil, got %v", v)
|
||||
}
|
||||
|
||||
if v := main.ExpandedAll("single"); len(v) != 1 || v[0].Id != "single" {
|
||||
t.Fatalf("Expected [single] slice, got %v", v)
|
||||
}
|
||||
|
||||
if v := main.ExpandedAll("multiple"); len(v) != 2 || v[0].Id != "multiple1" || v[1].Id != "multiple2" {
|
||||
t.Fatalf("Expected [multiple1, multiple2] slice, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSchemaData(t *testing.T) {
|
||||
collection := &models.Collection{
|
||||
Type: models.CollectionTypeAuth,
|
||||
@@ -665,6 +729,22 @@ func TestRecordSetAndGet(t *testing.T) {
|
||||
Name: "field2",
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
// fields that are not explicitly set to check
|
||||
// the default retrieval value (single and multiple)
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field4",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{MaxSelect: 2},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field5",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
@@ -677,28 +757,40 @@ func TestRecordSetAndGet(t *testing.T) {
|
||||
m.Set("unknown", 456) // undefined fields are allowed but not exported by default
|
||||
m.Set("expand", map[string]any{"test": 123}) // should store the value in m.expand
|
||||
|
||||
if m.Get("id") != "test_id" {
|
||||
t.Fatalf("Expected id %q, got %q", "test_id", m.Get("id"))
|
||||
if v := m.Get("id"); v != "test_id" {
|
||||
t.Fatalf("Expected id %q, got %q", "test_id", v)
|
||||
}
|
||||
|
||||
if m.GetString("created") != "2022-09-15 00:00:00.123Z" {
|
||||
t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", m.GetString("created"))
|
||||
if v := m.GetString("created"); v != "2022-09-15 00:00:00.123Z" {
|
||||
t.Fatalf("Expected created %q, got %q", "2022-09-15 00:00:00.123Z", v)
|
||||
}
|
||||
|
||||
if m.GetString("updated") != "" {
|
||||
t.Fatalf("Expected updated to be empty, got %q", m.GetString("updated"))
|
||||
if v := m.GetString("updated"); v != "" {
|
||||
t.Fatalf("Expected updated to be empty, got %q", v)
|
||||
}
|
||||
|
||||
if m.Get("field1") != "123" {
|
||||
t.Fatalf("Expected field1 %q, got %v", "123", m.Get("field1"))
|
||||
if v, ok := m.Get("field1").(string); !ok || v != "123" {
|
||||
t.Fatalf("Expected field1 %#v, got %#v", "123", m.Get("field1"))
|
||||
}
|
||||
|
||||
if m.Get("field2") != 0.0 {
|
||||
t.Fatalf("Expected field2 %v, got %v", 0.0, m.Get("field2"))
|
||||
if v, ok := m.Get("field2").(float64); !ok || v != 0.0 {
|
||||
t.Fatalf("Expected field2 %#v, got %#v", 0.0, m.Get("field2"))
|
||||
}
|
||||
|
||||
if m.Get("unknown") != 456 {
|
||||
t.Fatalf("Expected unknown %v, got %v", 456, m.Get("unknown"))
|
||||
if v, ok := m.Get("field3").(bool); !ok || v != false {
|
||||
t.Fatalf("Expected field3 %#v, got %#v", false, m.Get("field3"))
|
||||
}
|
||||
|
||||
if v, ok := m.Get("field4").([]string); !ok || len(v) != 0 {
|
||||
t.Fatalf("Expected field4 %#v, got %#v", "[]", m.Get("field4"))
|
||||
}
|
||||
|
||||
if v, ok := m.Get("field5").(string); !ok || len(v) != 0 {
|
||||
t.Fatalf("Expected field5 %#v, got %#v", "", m.Get("field5"))
|
||||
}
|
||||
|
||||
if v := m.Get("unknown"); v != 456 {
|
||||
t.Fatalf("Expected unknown %v, got %v", 456, v)
|
||||
}
|
||||
|
||||
if m.Expand()["test"] != 123 {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RequestData defines a HTTP request data struct, usually used
|
||||
// RequestInfo defines a HTTP request data struct, usually used
|
||||
// as part of the `@request.*` filter resolver.
|
||||
type RequestData struct {
|
||||
type RequestInfo struct {
|
||||
Method string `json:"method"`
|
||||
Query map[string]any `json:"query"`
|
||||
Data map[string]any `json:"data"`
|
||||
@@ -18,7 +18,7 @@ type RequestData struct {
|
||||
}
|
||||
|
||||
// HasModifierDataKeys loosely checks if the current struct has any modifier Data keys.
|
||||
func (r *RequestData) HasModifierDataKeys() bool {
|
||||
func (r *RequestInfo) HasModifierDataKeys() bool {
|
||||
allModifiers := schema.FieldValueModifiers()
|
||||
|
||||
for key := range r.Data {
|
||||
@@ -6,20 +6,20 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
func TestRequestDataHasModifierDataKeys(t *testing.T) {
|
||||
func TestRequestInfoHasModifierDataKeys(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
requestData *models.RequestData
|
||||
requestInfo *models.RequestInfo
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&models.RequestData{},
|
||||
&models.RequestInfo{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"Data with regular fields",
|
||||
&models.RequestData{
|
||||
&models.RequestInfo{
|
||||
Query: map[string]any{"data+": "demo"}, // should be ignored
|
||||
Data: map[string]any{"a": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
@@ -27,21 +27,21 @@ func TestRequestDataHasModifierDataKeys(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"Data with +modifier fields",
|
||||
&models.RequestData{
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"a+": 123, "b": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with -modifier fields",
|
||||
&models.RequestData{
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d": false},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"Data with mixed modifier fields",
|
||||
&models.RequestData{
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"a": 123, "b-": "test", "c.d+": false},
|
||||
},
|
||||
true,
|
||||
@@ -49,7 +49,7 @@ func TestRequestDataHasModifierDataKeys(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.requestData.HasModifierDataKeys()
|
||||
result := s.requestInfo.HasModifierDataKeys()
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
@@ -143,13 +143,17 @@ type SchemaField struct {
|
||||
func (f *SchemaField) ColDefinition() string {
|
||||
switch f.Type {
|
||||
case FieldTypeNumber:
|
||||
return "NUMERIC DEFAULT 0"
|
||||
return "NUMERIC DEFAULT 0 NOT NULL"
|
||||
case FieldTypeBool:
|
||||
return "BOOLEAN DEFAULT FALSE"
|
||||
return "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
case FieldTypeJson:
|
||||
return "JSON DEFAULT NULL"
|
||||
default:
|
||||
return "TEXT DEFAULT ''"
|
||||
if opt, ok := f.Options.(MultiValuer); ok && opt.IsMultiple() {
|
||||
return "JSON DEFAULT '[]' NOT NULL"
|
||||
}
|
||||
|
||||
return "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +657,7 @@ func (o RelationOptions) Validate() error {
|
||||
|
||||
return validation.ValidateStruct(&o,
|
||||
validation.Field(&o.CollectionId, validation.Required),
|
||||
validation.Field(&o.MinSelect, validation.NilOrNotEmpty, validation.Min(1)),
|
||||
validation.Field(&o.MinSelect, validation.Min(0)),
|
||||
validation.Field(&o.MaxSelect, validation.NilOrNotEmpty, validation.Min(minVal)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,47 +63,59 @@ func TestSchemaFieldColDefinition(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeText, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeNumber, Name: "test"},
|
||||
"NUMERIC DEFAULT 0",
|
||||
"NUMERIC DEFAULT 0 NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeBool, Name: "test"},
|
||||
"BOOLEAN DEFAULT FALSE",
|
||||
"BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeEmail, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeUrl, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeEditor, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeDate, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeJson, Name: "test"},
|
||||
"JSON DEFAULT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Name: "test"},
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test"},
|
||||
"TEXT DEFAULT ''",
|
||||
schema.SchemaField{Type: schema.FieldTypeSelect, Name: "test_multiple", Options: &schema.SelectOptions{MaxSelect: 2}},
|
||||
"JSON DEFAULT '[]' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Name: "test"},
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeFile, Name: "test_multiple", Options: &schema.FileOptions{MaxSelect: 2}},
|
||||
"JSON DEFAULT '[]' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test", Options: &schema.RelationOptions{MaxSelect: types.Pointer(1)}},
|
||||
"TEXT DEFAULT '' NOT NULL",
|
||||
},
|
||||
{
|
||||
schema.SchemaField{Type: schema.FieldTypeRelation, Name: "test_multiple", Options: &schema.RelationOptions{MaxSelect: nil}},
|
||||
"JSON DEFAULT '[]' NOT NULL",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2121,6 +2133,22 @@ func TestRelationOptionsValidate(t *testing.T) {
|
||||
},
|
||||
[]string{"collectionId"},
|
||||
},
|
||||
{
|
||||
"MinSelect < 0",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: types.Pointer(-1),
|
||||
},
|
||||
[]string{"minSelect"},
|
||||
},
|
||||
{
|
||||
"MinSelect >= 0",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: types.Pointer(0),
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MaxSelect <= 0",
|
||||
schema.RelationOptions{
|
||||
@@ -2137,6 +2165,42 @@ func TestRelationOptionsValidate(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MinSelect < MaxSelect",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: nil,
|
||||
MaxSelect: types.Pointer(1),
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MinSelect = MaxSelect (non-zero)",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: types.Pointer(1),
|
||||
MaxSelect: types.Pointer(1),
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"MinSelect = MaxSelect (both zero)",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: types.Pointer(0),
|
||||
MaxSelect: types.Pointer(0),
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
{
|
||||
"MinSelect > MaxSelect",
|
||||
schema.RelationOptions{
|
||||
CollectionId: "abc",
|
||||
MinSelect: types.Pointer(2),
|
||||
MaxSelect: types.Pointer(1),
|
||||
},
|
||||
[]string{"maxSelect"},
|
||||
},
|
||||
}
|
||||
|
||||
checkFieldOptionsScenarios(t, scenarios)
|
||||
|
||||
@@ -60,6 +60,9 @@ type Settings struct {
|
||||
OIDC2Auth AuthProviderConfig `form:"oidc2Auth" json:"oidc2Auth"`
|
||||
OIDC3Auth AuthProviderConfig `form:"oidc3Auth" json:"oidc3Auth"`
|
||||
AppleAuth AuthProviderConfig `form:"appleAuth" json:"appleAuth"`
|
||||
InstagramAuth AuthProviderConfig `form:"instagramAuth" json:"instagramAuth"`
|
||||
VKAuth AuthProviderConfig `form:"vkAuth" json:"vkAuth"`
|
||||
YandexAuth AuthProviderConfig `form:"yandexAuth" json:"yandexAuth"`
|
||||
}
|
||||
|
||||
// New creates and returns a new default Settings instance.
|
||||
@@ -175,6 +178,15 @@ func New() *Settings {
|
||||
AppleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
InstagramAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
VKAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
YandexAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +227,9 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.OIDC2Auth),
|
||||
validation.Field(&s.OIDC3Auth),
|
||||
validation.Field(&s.AppleAuth),
|
||||
validation.Field(&s.InstagramAuth),
|
||||
validation.Field(&s.VKAuth),
|
||||
validation.Field(&s.YandexAuth),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,6 +293,9 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.OIDC2Auth.ClientSecret,
|
||||
&clone.OIDC3Auth.ClientSecret,
|
||||
&clone.AppleAuth.ClientSecret,
|
||||
&clone.InstagramAuth.ClientSecret,
|
||||
&clone.VKAuth.ClientSecret,
|
||||
&clone.YandexAuth.ClientSecret,
|
||||
}
|
||||
|
||||
// mask all sensitive fields
|
||||
@@ -315,6 +333,9 @@ func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig {
|
||||
auth.NameOIDC + "2": s.OIDC2Auth,
|
||||
auth.NameOIDC + "3": s.OIDC3Auth,
|
||||
auth.NameApple: s.AppleAuth,
|
||||
auth.NameInstagram: s.InstagramAuth,
|
||||
auth.NameVK: s.VKAuth,
|
||||
auth.NameYandex: s.YandexAuth,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,12 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.OIDC3Auth.ClientId = ""
|
||||
s.AppleAuth.Enabled = true
|
||||
s.AppleAuth.ClientId = ""
|
||||
s.InstagramAuth.Enabled = true
|
||||
s.InstagramAuth.ClientId = ""
|
||||
s.VKAuth.Enabled = true
|
||||
s.VKAuth.ClientId = ""
|
||||
s.YandexAuth.Enabled = true
|
||||
s.YandexAuth.ClientId = ""
|
||||
|
||||
// check if Validate() is triggering the members validate methods.
|
||||
err := s.Validate()
|
||||
@@ -105,6 +111,9 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@@ -172,6 +181,12 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.OIDC3Auth.ClientId = "oidc3_test"
|
||||
s2.AppleAuth.Enabled = true
|
||||
s2.AppleAuth.ClientId = "apple_test"
|
||||
s2.InstagramAuth.Enabled = true
|
||||
s2.InstagramAuth.ClientId = "instagram_test"
|
||||
s2.VKAuth.Enabled = true
|
||||
s2.VKAuth.ClientId = "vk_test"
|
||||
s2.YandexAuth.Enabled = true
|
||||
s2.YandexAuth.ClientId = "yandex_test"
|
||||
|
||||
if err := s1.Merge(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -259,6 +274,9 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.OIDC2Auth.ClientSecret = testSecret
|
||||
s1.OIDC3Auth.ClientSecret = testSecret
|
||||
s1.AppleAuth.ClientSecret = testSecret
|
||||
s1.InstagramAuth.ClientSecret = testSecret
|
||||
s1.VKAuth.ClientSecret = testSecret
|
||||
s1.YandexAuth.ClientSecret = testSecret
|
||||
|
||||
s1Bytes, err := json.Marshal(s1)
|
||||
if err != nil {
|
||||
@@ -314,6 +332,9 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
s.OIDC2Auth.ClientId = "oidc2_test"
|
||||
s.OIDC3Auth.ClientId = "oidc3_test"
|
||||
s.AppleAuth.ClientId = "apple_test"
|
||||
s.InstagramAuth.ClientId = "instagram_test"
|
||||
s.VKAuth.ClientId = "vk_test"
|
||||
s.YandexAuth.ClientId = "yandex_test"
|
||||
|
||||
result := s.NamedAuthProviderConfigs()
|
||||
|
||||
@@ -342,6 +363,9 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
`"oidc2":{"enabled":false,"clientId":"oidc2_test"`,
|
||||
`"oidc3":{"enabled":false,"clientId":"oidc3_test"`,
|
||||
`"apple":{"enabled":false,"clientId":"apple_test"`,
|
||||
`"instagram":{"enabled":false,"clientId":"instagram_test"`,
|
||||
`"vk":{"enabled":false,"clientId":"vk_test"`,
|
||||
`"yandex":{"enabled":false,"clientId":"yandex_test"`,
|
||||
}
|
||||
for _, p := range expectedParts {
|
||||
if !strings.Contains(encodedStr, p) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Package ghupdate implements a new command to selfupdate the current
|
||||
// PocketBase executable with the latest GitHub release.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
|
||||
package ghupdate
|
||||
|
||||
import (
|
||||
@@ -16,6 +20,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
@@ -27,10 +32,10 @@ type HttpClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Options defines optional struct to customize the default plugin behavior.
|
||||
// Config defines the config options of the ghupdate plugin.
|
||||
//
|
||||
// NB! This plugin is considered experimental and its options may change in the future.
|
||||
type Options struct {
|
||||
// NB! This plugin is considered experimental and its config options may change in the future.
|
||||
type Config struct {
|
||||
// Owner specifies the account owner of the repository (default to "pocketbase").
|
||||
Owner string
|
||||
|
||||
@@ -51,43 +56,38 @@ type Options struct {
|
||||
|
||||
// MustRegister registers the ghupdate plugin to the provided app instance
|
||||
// and panic if it fails.
|
||||
func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) {
|
||||
if err := Register(app, rootCmd, options); err != nil {
|
||||
func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
|
||||
if err := Register(app, rootCmd, config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the ghupdate plugin to the provided app instance.
|
||||
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
|
||||
p := &plugin{
|
||||
app: app,
|
||||
currentVersion: rootCmd.Version,
|
||||
config: config,
|
||||
}
|
||||
|
||||
if options != nil {
|
||||
p.options = options
|
||||
} else {
|
||||
p.options = &Options{}
|
||||
if p.config.Owner == "" {
|
||||
p.config.Owner = "pocketbase"
|
||||
}
|
||||
|
||||
if p.options.Owner == "" {
|
||||
p.options.Owner = "pocketbase"
|
||||
if p.config.Repo == "" {
|
||||
p.config.Repo = "pocketbase"
|
||||
}
|
||||
|
||||
if p.options.Repo == "" {
|
||||
p.options.Repo = "pocketbase"
|
||||
if p.config.ArchiveExecutable == "" {
|
||||
p.config.ArchiveExecutable = "pocketbase"
|
||||
}
|
||||
|
||||
if p.options.ArchiveExecutable == "" {
|
||||
p.options.ArchiveExecutable = "pocketbase"
|
||||
if p.config.HttpClient == nil {
|
||||
p.config.HttpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
if p.options.HttpClient == nil {
|
||||
p.options.HttpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
if p.options.Context == nil {
|
||||
p.options.Context = context.Background()
|
||||
if p.config.Context == nil {
|
||||
p.config.Context = context.Background()
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(p.updateCmd())
|
||||
@@ -98,7 +98,7 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
type plugin struct {
|
||||
app core.App
|
||||
currentVersion string
|
||||
options *Options
|
||||
config Config
|
||||
}
|
||||
|
||||
func (p *plugin) updateCmd() *cobra.Command {
|
||||
@@ -112,6 +112,29 @@ func (p *plugin) updateCmd() *cobra.Command {
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
var needConfirm bool
|
||||
if isMaybeRunningInDocker() {
|
||||
needConfirm = true
|
||||
color.Yellow("NB! It seems that you are in a Docker container.")
|
||||
color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
|
||||
} else if isMaybeRunningInNixOS() {
|
||||
needConfirm = true
|
||||
color.Yellow("NB! It seems that you are in a NixOS.")
|
||||
color.Yellow("Due to the non-standard filesystem implementation of the environment, the update command may not work as expected.")
|
||||
}
|
||||
|
||||
if needConfirm {
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: "Do you want to proceed with the update?",
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return p.update(withBackup)
|
||||
},
|
||||
}
|
||||
@@ -130,10 +153,10 @@ func (p *plugin) update(withBackup bool) error {
|
||||
color.Yellow("Fetching release information...")
|
||||
|
||||
latest, err := fetchLatestRelease(
|
||||
p.options.Context,
|
||||
p.options.HttpClient,
|
||||
p.options.Owner,
|
||||
p.options.Repo,
|
||||
p.config.Context,
|
||||
p.config.HttpClient,
|
||||
p.config.Owner,
|
||||
p.config.Repo,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -161,7 +184,7 @@ func (p *plugin) update(withBackup bool) error {
|
||||
|
||||
// download the release asset
|
||||
assetZip := filepath.Join(releaseDir, asset.Name)
|
||||
if err := downloadFile(p.options.Context, p.options.HttpClient, asset.DownloadUrl, assetZip); err != nil {
|
||||
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -183,7 +206,7 @@ func (p *plugin) update(withBackup bool) error {
|
||||
renamedOldExec := oldExec + ".old"
|
||||
defer os.Remove(renamedOldExec)
|
||||
|
||||
newExec := filepath.Join(extractDir, p.options.ArchiveExecutable)
|
||||
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
|
||||
if _, err := os.Stat(newExec); err != nil {
|
||||
// try again with an .exe extension
|
||||
newExec = newExec + ".exe"
|
||||
@@ -213,14 +236,14 @@ func (p *plugin) update(withBackup bool) error {
|
||||
color.Yellow("Creating pb_data backup...")
|
||||
|
||||
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
|
||||
if err := p.app.CreateBackup(p.options.Context, backupName); err != nil {
|
||||
if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
|
||||
tryToRevertExecChanges()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
color.HiBlack("---")
|
||||
color.Green("Update completed sucessfully! You can start the executable as usual.")
|
||||
color.Green("Update completed successfully! You can start the executable as usual.")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -370,3 +393,16 @@ func compareVersions(a, b string) int {
|
||||
|
||||
return 0 // equal
|
||||
}
|
||||
|
||||
// note: not completely reliable as it may not work on all platforms
|
||||
// but should at least provide a warning for the most common use cases
|
||||
func isMaybeRunningInDocker() bool {
|
||||
_, err := os.Stat("/.dockerenv")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// note: untested
|
||||
func isMaybeRunningInNixOS() bool {
|
||||
_, err := os.Stat("/etc/NIXOS")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ func TestCompareVersions(t *testing.T) {
|
||||
{"1.10", "1.9", -1},
|
||||
{"1.2", "1.12", 1},
|
||||
{"3.2", "1.6", -1},
|
||||
{"0.0.2", "0.0.1", -1},
|
||||
{"0.16.2", "0.17.0", 1},
|
||||
{"1.15.0", "0.16.1", -1},
|
||||
{"3.2", "4.0", 1},
|
||||
{"3.2.4", "3.2.3", -1},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// hooksBinds adds wrapped "on*" hook methods by reflecting on core.App.
|
||||
func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
fm := FieldMapper{}
|
||||
|
||||
appType := reflect.TypeOf(app)
|
||||
appValue := reflect.ValueOf(app)
|
||||
totalMethods := appType.NumMethod()
|
||||
excludeHooks := []string{"OnBeforeServe"}
|
||||
|
||||
for i := 0; i < totalMethods; i++ {
|
||||
method := appType.Method(i)
|
||||
if !strings.HasPrefix(method.Name, "On") || list.ExistInSlice(method.Name, excludeHooks) {
|
||||
continue // not a hook or excluded
|
||||
}
|
||||
|
||||
jsName := fm.MethodName(appType, method)
|
||||
|
||||
// register the hook to the loader
|
||||
loader.Set(jsName, func(callback string, tags ...string) {
|
||||
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
|
||||
|
||||
tagsAsValues := make([]reflect.Value, len(tags))
|
||||
for i, tag := range tags {
|
||||
tagsAsValues[i] = reflect.ValueOf(tag)
|
||||
}
|
||||
|
||||
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
|
||||
addFunc := hookInstance.MethodByName("Add")
|
||||
|
||||
handlerType := addFunc.Type().In(0)
|
||||
|
||||
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
|
||||
handlerArgs := make([]any, len(args))
|
||||
for i, arg := range args {
|
||||
handlerArgs[i] = arg.Interface()
|
||||
}
|
||||
|
||||
err := executors.run(func(executor *goja.Runtime) error {
|
||||
executor.Set("__args", handlerArgs)
|
||||
res, err := executor.RunProgram(pr)
|
||||
executor.Set("__args", goja.Undefined())
|
||||
|
||||
// check for returned error or false
|
||||
if res != nil {
|
||||
switch v := res.Export().(type) {
|
||||
case error:
|
||||
return v
|
||||
case bool:
|
||||
if !v {
|
||||
return hook.StopPropagation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||
})
|
||||
|
||||
// register the wrapped hook handler
|
||||
addFunc.Call([]reflect.Value{handler})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
scheduler := cron.New()
|
||||
|
||||
var wasServeTriggered bool
|
||||
|
||||
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
|
||||
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
|
||||
|
||||
err := scheduler.Add(jobId, cronExpr, func() {
|
||||
executors.run(func(executor *goja.Runtime) error {
|
||||
_, err := executor.RunProgram(pr)
|
||||
return err
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
|
||||
}
|
||||
|
||||
// start the ticker (if not already)
|
||||
if wasServeTriggered && scheduler.Total() > 0 && !scheduler.HasStarted() {
|
||||
scheduler.Start()
|
||||
}
|
||||
})
|
||||
|
||||
loader.Set("cronRemove", func(jobId string) {
|
||||
scheduler.Remove(jobId)
|
||||
|
||||
// stop the ticker if there are no other jobs
|
||||
if scheduler.Total() == 0 {
|
||||
scheduler.Stop()
|
||||
}
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// start the ticker (if not already)
|
||||
if scheduler.Total() > 0 && !scheduler.HasStarted() {
|
||||
scheduler.Start()
|
||||
}
|
||||
|
||||
wasServeTriggered = true
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func routerBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
|
||||
loader.Set("routerAdd", func(method string, path string, handler goja.Value, middlewares ...goja.Value) {
|
||||
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
|
||||
if err != nil {
|
||||
panic("[routerAdd] failed to wrap middlewares: " + err.Error())
|
||||
}
|
||||
|
||||
wrappedHandler, err := wrapHandler(executors, handler)
|
||||
if err != nil {
|
||||
panic("[routerAdd] failed to wrap handler: " + err.Error())
|
||||
}
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
e.Router.Add(strings.ToUpper(method), path, wrappedHandler, wrappedMiddlewares...)
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
loader.Set("routerUse", func(middlewares ...goja.Value) {
|
||||
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
|
||||
if err != nil {
|
||||
panic("[routerUse] failed to wrap middlewares: " + err.Error())
|
||||
}
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
e.Router.Use(wrappedMiddlewares...)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
loader.Set("routerPre", func(middlewares ...goja.Value) {
|
||||
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
|
||||
if err != nil {
|
||||
panic("[routerPre] failed to wrap middlewares: " + err.Error())
|
||||
}
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
e.Router.Pre(wrappedMiddlewares...)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func wrapHandler(executors *vmsPool, handler goja.Value) (echo.HandlerFunc, error) {
|
||||
if handler == nil {
|
||||
return nil, errors.New("handler must be non-nil")
|
||||
}
|
||||
|
||||
switch h := handler.Export().(type) {
|
||||
case echo.HandlerFunc:
|
||||
// "native" handler - no need to wrap
|
||||
return h, nil
|
||||
case func(goja.FunctionCall) goja.Value, string:
|
||||
pr := goja.MustCompile("", "{("+handler.String()+").apply(undefined, __args)}", true)
|
||||
|
||||
wrappedHandler := func(c echo.Context) error {
|
||||
return executors.run(func(executor *goja.Runtime) error {
|
||||
executor.Set("__args", []any{c})
|
||||
res, err := executor.RunProgram(pr)
|
||||
executor.Set("__args", goja.Undefined())
|
||||
|
||||
// check for returned error
|
||||
if res != nil {
|
||||
if v, ok := res.Export().(error); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
return wrappedHandler, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported goja handler type")
|
||||
}
|
||||
}
|
||||
|
||||
func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]echo.MiddlewareFunc, error) {
|
||||
wrappedMiddlewares := make([]echo.MiddlewareFunc, len(rawMiddlewares))
|
||||
|
||||
for i, m := range rawMiddlewares {
|
||||
if m == nil {
|
||||
return nil, errors.New("middleware func must be non-nil")
|
||||
}
|
||||
|
||||
switch v := m.Export().(type) {
|
||||
case echo.MiddlewareFunc:
|
||||
// "native" middleware - no need to wrap
|
||||
wrappedMiddlewares[i] = v
|
||||
case func(goja.FunctionCall) goja.Value, string:
|
||||
pr := goja.MustCompile("", "{(("+m.String()+").apply(undefined, __args)).apply(undefined, __args2)}", true)
|
||||
|
||||
wrappedMiddlewares[i] = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return executors.run(func(executor *goja.Runtime) error {
|
||||
executor.Set("__args", []any{next})
|
||||
executor.Set("__args2", []any{c})
|
||||
res, err := executor.RunProgram(pr)
|
||||
executor.Set("__args", goja.Undefined())
|
||||
executor.Set("__args2", goja.Undefined())
|
||||
|
||||
// check for returned error
|
||||
if res != nil {
|
||||
if v, ok := res.Export().(error); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("unsupported goja middleware type")
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedMiddlewares, nil
|
||||
}
|
||||
|
||||
func baseBinds(vm *goja.Runtime) {
|
||||
vm.SetFieldNameMapper(FieldMapper{})
|
||||
|
||||
vm.Set("arrayOf", func(model any) any {
|
||||
mt := reflect.TypeOf(model)
|
||||
st := reflect.SliceOf(mt)
|
||||
elem := reflect.New(st).Elem()
|
||||
|
||||
return elem.Addr().Interface()
|
||||
})
|
||||
|
||||
vm.Set("DynamicModel", func(call goja.ConstructorCall) *goja.Object {
|
||||
shape, ok := call.Argument(0).Export().(map[string]any)
|
||||
if !ok || len(shape) == 0 {
|
||||
panic("[DynamicModel] missing shape data")
|
||||
}
|
||||
|
||||
instance := newDynamicModel(shape)
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
|
||||
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
|
||||
var instance *models.Record
|
||||
|
||||
collection, ok := call.Argument(0).Export().(*models.Collection)
|
||||
if ok {
|
||||
instance = models.NewRecord(collection)
|
||||
data, ok := call.Argument(1).Export().(map[string]any)
|
||||
if ok {
|
||||
instance.Load(data)
|
||||
}
|
||||
} else {
|
||||
instance = &models.Record{}
|
||||
}
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
|
||||
vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &models.Collection{}
|
||||
return structConstructorUnmarshal(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &models.Admin{}
|
||||
return structConstructorUnmarshal(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Schema", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &schema.Schema{}
|
||||
return structConstructorUnmarshal(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("SchemaField", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &schema.SchemaField{}
|
||||
return structConstructorUnmarshal(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("MailerMessage", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &mailer.Message{}
|
||||
return structConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Command", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &cobra.Command{}
|
||||
return structConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("RequestInfo", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &models.RequestInfo{}
|
||||
return structConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("DateTime", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := types.NowDateTime()
|
||||
|
||||
val, _ := call.Argument(0).Export().(string)
|
||||
if val != "" {
|
||||
instance, _ = types.ParseDateTime(val)
|
||||
}
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return structConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("ValidationError", func(call goja.ConstructorCall) *goja.Object {
|
||||
code, _ := call.Argument(0).Export().(string)
|
||||
message, _ := call.Argument(1).Export().(string)
|
||||
|
||||
instance := validation.NewError(code, message)
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
|
||||
vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object {
|
||||
concurrentDB, _ := call.Argument(0).Export().(dbx.Builder)
|
||||
if concurrentDB == nil {
|
||||
panic("[Dao] missing required Dao(concurrentDB, [nonconcurrentDB]) argument")
|
||||
}
|
||||
|
||||
nonConcurrentDB, _ := call.Argument(1).Export().(dbx.Builder)
|
||||
if nonConcurrentDB == nil {
|
||||
nonConcurrentDB = concurrentDB
|
||||
}
|
||||
|
||||
instance := daos.NewMultiDB(concurrentDB, nonConcurrentDB)
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
}
|
||||
|
||||
func dbxBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$dbx", obj)
|
||||
|
||||
obj.Set("exp", dbx.NewExp)
|
||||
obj.Set("hashExp", func(data map[string]any) dbx.HashExp {
|
||||
return dbx.HashExp(data)
|
||||
})
|
||||
obj.Set("not", dbx.Not)
|
||||
obj.Set("and", dbx.And)
|
||||
obj.Set("or", dbx.Or)
|
||||
obj.Set("in", dbx.In)
|
||||
obj.Set("notIn", dbx.NotIn)
|
||||
obj.Set("like", dbx.Like)
|
||||
obj.Set("orLike", dbx.OrLike)
|
||||
obj.Set("notLike", dbx.NotLike)
|
||||
obj.Set("orNotLike", dbx.OrNotLike)
|
||||
obj.Set("exists", dbx.Exists)
|
||||
obj.Set("notExists", dbx.NotExists)
|
||||
obj.Set("between", dbx.Between)
|
||||
obj.Set("notBetween", dbx.NotBetween)
|
||||
}
|
||||
|
||||
func tokensBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$tokens", obj)
|
||||
|
||||
// admin
|
||||
obj.Set("adminAuthToken", tokens.NewAdminAuthToken)
|
||||
obj.Set("adminResetPasswordToken", tokens.NewAdminResetPasswordToken)
|
||||
obj.Set("adminFileToken", tokens.NewAdminFileToken)
|
||||
|
||||
// record
|
||||
obj.Set("recordAuthToken", tokens.NewRecordAuthToken)
|
||||
obj.Set("recordVerifyToken", tokens.NewRecordVerifyToken)
|
||||
obj.Set("recordResetPasswordToken", tokens.NewRecordResetPasswordToken)
|
||||
obj.Set("recordChangeEmailToken", tokens.NewRecordChangeEmailToken)
|
||||
obj.Set("recordFileToken", tokens.NewRecordFileToken)
|
||||
}
|
||||
|
||||
func securityBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$security", obj)
|
||||
|
||||
// random
|
||||
obj.Set("randomString", security.RandomString)
|
||||
obj.Set("randomStringWithAlphabet", security.RandomStringWithAlphabet)
|
||||
obj.Set("pseudorandomString", security.PseudorandomString)
|
||||
obj.Set("pseudorandomStringWithAlphabet", security.PseudorandomStringWithAlphabet)
|
||||
|
||||
// jwt
|
||||
obj.Set("parseUnverifiedJWT", security.ParseUnverifiedJWT)
|
||||
obj.Set("parseJWT", security.ParseJWT)
|
||||
obj.Set("createJWT", security.NewJWT)
|
||||
|
||||
// encryption
|
||||
obj.Set("encrypt", security.Encrypt)
|
||||
obj.Set("decrypt", func(cipherText, key string) (string, error) {
|
||||
result, err := security.Decrypt(cipherText, key)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(result), err
|
||||
})
|
||||
}
|
||||
|
||||
func filesystemBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$filesystem", obj)
|
||||
|
||||
obj.Set("fileFromPath", filesystem.NewFileFromPath)
|
||||
obj.Set("fileFromBytes", filesystem.NewFileFromBytes)
|
||||
obj.Set("fileFromMultipart", filesystem.NewFileFromMultipart)
|
||||
}
|
||||
|
||||
func filepathBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$filepath", obj)
|
||||
|
||||
obj.Set("base", filepath.Base)
|
||||
obj.Set("clean", filepath.Clean)
|
||||
obj.Set("dir", filepath.Dir)
|
||||
obj.Set("ext", filepath.Ext)
|
||||
obj.Set("fromSlash", filepath.FromSlash)
|
||||
obj.Set("glob", filepath.Glob)
|
||||
obj.Set("isAbs", filepath.IsAbs)
|
||||
obj.Set("join", filepath.Join)
|
||||
obj.Set("match", filepath.Match)
|
||||
obj.Set("rel", filepath.Rel)
|
||||
obj.Set("split", filepath.Split)
|
||||
obj.Set("splitList", filepath.SplitList)
|
||||
obj.Set("toSlash", filepath.ToSlash)
|
||||
obj.Set("walk", filepath.Walk)
|
||||
obj.Set("walkDir", filepath.WalkDir)
|
||||
}
|
||||
|
||||
func osBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$os", obj)
|
||||
|
||||
obj.Set("args", os.Args)
|
||||
obj.Set("exec", exec.Command)
|
||||
obj.Set("exit", os.Exit)
|
||||
obj.Set("getenv", os.Getenv)
|
||||
obj.Set("dirFS", os.DirFS)
|
||||
obj.Set("readFile", os.ReadFile)
|
||||
obj.Set("writeFile", os.WriteFile)
|
||||
obj.Set("readDir", os.ReadDir)
|
||||
obj.Set("tempDir", os.TempDir)
|
||||
obj.Set("truncate", os.Truncate)
|
||||
obj.Set("getwd", os.Getwd)
|
||||
obj.Set("mkdir", os.Mkdir)
|
||||
obj.Set("mkdirAll", os.MkdirAll)
|
||||
obj.Set("rename", os.Rename)
|
||||
obj.Set("remove", os.Remove)
|
||||
obj.Set("removeAll", os.RemoveAll)
|
||||
}
|
||||
|
||||
func formsBinds(vm *goja.Runtime) {
|
||||
registerFactoryAsConstructor(vm, "AdminLoginForm", forms.NewAdminLogin)
|
||||
registerFactoryAsConstructor(vm, "AdminPasswordResetConfirmForm", forms.NewAdminPasswordResetConfirm)
|
||||
registerFactoryAsConstructor(vm, "AdminPasswordResetRequestForm", forms.NewAdminPasswordResetRequest)
|
||||
registerFactoryAsConstructor(vm, "AdminUpsertForm", forms.NewAdminUpsert)
|
||||
registerFactoryAsConstructor(vm, "AppleClientSecretCreateForm", forms.NewAppleClientSecretCreate)
|
||||
registerFactoryAsConstructor(vm, "CollectionUpsertForm", forms.NewCollectionUpsert)
|
||||
registerFactoryAsConstructor(vm, "CollectionsImportForm", forms.NewCollectionsImport)
|
||||
registerFactoryAsConstructor(vm, "RealtimeSubscribeForm", forms.NewRealtimeSubscribe)
|
||||
registerFactoryAsConstructor(vm, "RecordEmailChangeConfirmForm", forms.NewRecordEmailChangeConfirm)
|
||||
registerFactoryAsConstructor(vm, "RecordEmailChangeRequestForm", forms.NewRecordEmailChangeRequest)
|
||||
registerFactoryAsConstructor(vm, "RecordOAuth2LoginForm", forms.NewRecordOAuth2Login)
|
||||
registerFactoryAsConstructor(vm, "RecordPasswordLoginForm", forms.NewRecordPasswordLogin)
|
||||
registerFactoryAsConstructor(vm, "RecordPasswordResetConfirmForm", forms.NewRecordPasswordResetConfirm)
|
||||
registerFactoryAsConstructor(vm, "RecordPasswordResetRequestForm", forms.NewRecordPasswordResetRequest)
|
||||
registerFactoryAsConstructor(vm, "RecordUpsertForm", forms.NewRecordUpsert)
|
||||
registerFactoryAsConstructor(vm, "RecordVerificationConfirmForm", forms.NewRecordVerificationConfirm)
|
||||
registerFactoryAsConstructor(vm, "RecordVerificationRequestForm", forms.NewRecordVerificationRequest)
|
||||
registerFactoryAsConstructor(vm, "SettingsUpsertForm", forms.NewSettingsUpsert)
|
||||
registerFactoryAsConstructor(vm, "TestEmailSendForm", forms.NewTestEmailSend)
|
||||
registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem)
|
||||
}
|
||||
|
||||
func apisBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$apis", obj)
|
||||
|
||||
obj.Set("staticDirectoryHandler", func(dir string, indexFallback bool) echo.HandlerFunc {
|
||||
return apis.StaticDirectoryHandler(os.DirFS(dir), indexFallback)
|
||||
})
|
||||
|
||||
// middlewares
|
||||
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
|
||||
obj.Set("requireAdminAuth", apis.RequireAdminAuth)
|
||||
obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
|
||||
obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
|
||||
obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
|
||||
obj.Set("activityLogger", apis.ActivityLogger)
|
||||
|
||||
// record helpers
|
||||
obj.Set("requestInfo", apis.RequestInfo)
|
||||
obj.Set("recordAuthResponse", apis.RecordAuthResponse)
|
||||
obj.Set("enrichRecord", apis.EnrichRecord)
|
||||
obj.Set("enrichRecords", apis.EnrichRecords)
|
||||
|
||||
// api errors
|
||||
registerFactoryAsConstructor(vm, "ApiError", apis.NewApiError)
|
||||
registerFactoryAsConstructor(vm, "NotFoundError", apis.NewNotFoundError)
|
||||
registerFactoryAsConstructor(vm, "BadRequestError", apis.NewBadRequestError)
|
||||
registerFactoryAsConstructor(vm, "ForbiddenError", apis.NewForbiddenError)
|
||||
registerFactoryAsConstructor(vm, "UnauthorizedError", apis.NewUnauthorizedError)
|
||||
}
|
||||
|
||||
func httpClientBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$http", obj)
|
||||
|
||||
type sendResult struct {
|
||||
StatusCode int
|
||||
Raw string
|
||||
Json any
|
||||
}
|
||||
|
||||
type sendConfig struct {
|
||||
Method string
|
||||
Url string
|
||||
Body string
|
||||
Headers map[string]string
|
||||
Timeout int // seconds (default to 120)
|
||||
Data map[string]any // deprecated, consider using Body instead
|
||||
}
|
||||
|
||||
obj.Set("send", func(params map[string]any) (*sendResult, error) {
|
||||
rawParams, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := sendConfig{
|
||||
Method: "GET",
|
||||
}
|
||||
if err := json.Unmarshal(rawParams, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var reqBody io.Reader
|
||||
|
||||
// legacy json body data
|
||||
if len(config.Data) != 0 {
|
||||
encoded, err := json.Marshal(config.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
if config.Body != "" {
|
||||
reqBody = strings.NewReader(config.Body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(config.Method), config.Url, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range config.Headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
// set default content-type header (if missing)
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
bodyRaw, _ := io.ReadAll(res.Body)
|
||||
|
||||
result := &sendResult{
|
||||
StatusCode: res.StatusCode,
|
||||
Raw: string(bodyRaw),
|
||||
}
|
||||
|
||||
if len(result.Raw) != 0 {
|
||||
// try as map
|
||||
result.Json = map[string]any{}
|
||||
if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
|
||||
// try as slice
|
||||
result.Json = []any{}
|
||||
if err := json.Unmarshal(bodyRaw, &result.Json); err != nil {
|
||||
result.Json = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// registerFactoryAsConstructor registers the factory function as native JS constructor.
|
||||
//
|
||||
// If there is missing or nil arguments, their type zero value is used.
|
||||
func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) {
|
||||
rv := reflect.ValueOf(factoryFunc)
|
||||
rt := reflect.TypeOf(factoryFunc)
|
||||
totalArgs := rt.NumIn()
|
||||
|
||||
vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object {
|
||||
args := make([]reflect.Value, totalArgs)
|
||||
|
||||
for i := 0; i < totalArgs; i++ {
|
||||
v := call.Argument(i).Export()
|
||||
|
||||
// use the arg type zero value
|
||||
if v == nil {
|
||||
args[i] = reflect.New(rt.In(i)).Elem()
|
||||
} else if number, ok := v.(int64); ok {
|
||||
// goja uses int64 for "int"-like numbers but we rarely do that and use int most of the times
|
||||
// (at later stage we can use reflection on the arguments to validate the types in case this is not sufficient anymore)
|
||||
args[i] = reflect.ValueOf(int(number))
|
||||
} else {
|
||||
args[i] = reflect.ValueOf(v)
|
||||
}
|
||||
}
|
||||
|
||||
result := rv.Call(args)
|
||||
|
||||
if len(result) != 1 {
|
||||
panic("the factory function should return only 1 item")
|
||||
}
|
||||
|
||||
value := vm.ToValue(result[0].Interface()).(*goja.Object)
|
||||
value.SetPrototype(call.This.Prototype())
|
||||
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
// structConstructor wraps the provided struct with a native JS constructor.
|
||||
//
|
||||
// If the constructor argument is a map, each entry of the map will be loaded into the wrapped goja.Object.
|
||||
func structConstructor(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object {
|
||||
data, _ := call.Argument(0).Export().(map[string]any)
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
for k, v := range data {
|
||||
instanceValue.Set(k, v)
|
||||
}
|
||||
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
}
|
||||
|
||||
// structConstructorUnmarshal wraps the provided struct with a native JS constructor.
|
||||
//
|
||||
// The constructor first argument will be loaded via json.Unmarshal into the instance.
|
||||
func structConstructorUnmarshal(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object {
|
||||
if data := call.Argument(0).Export(); data != nil {
|
||||
if raw, err := json.Marshal(data); err == nil {
|
||||
json.Unmarshal(raw, instance)
|
||||
}
|
||||
}
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
}
|
||||
|
||||
// newDynamicModel creates a new dynamic struct with fields based
|
||||
// on the specified "shape".
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// m := newDynamicModel(map[string]any{
|
||||
// "title": "",
|
||||
// "total": 0,
|
||||
// })
|
||||
func newDynamicModel(shape map[string]any) any {
|
||||
shapeValues := make([]reflect.Value, 0, len(shape))
|
||||
structFields := make([]reflect.StructField, 0, len(shape))
|
||||
|
||||
for k, v := range shape {
|
||||
vt := reflect.TypeOf(v)
|
||||
|
||||
switch kind := vt.Kind(); kind {
|
||||
case reflect.Map:
|
||||
raw, _ := json.Marshal(v)
|
||||
newV := types.JsonMap{}
|
||||
newV.Scan(raw)
|
||||
v = newV
|
||||
vt = reflect.TypeOf(v)
|
||||
case reflect.Slice, reflect.Array:
|
||||
raw, _ := json.Marshal(v)
|
||||
newV := types.JsonArray[any]{}
|
||||
newV.Scan(raw)
|
||||
v = newV
|
||||
vt = reflect.TypeOf(newV)
|
||||
}
|
||||
|
||||
shapeValues = append(shapeValues, reflect.ValueOf(v))
|
||||
|
||||
structFields = append(structFields, reflect.StructField{
|
||||
Name: inflector.UcFirst(k), // ensures that the field is exportable
|
||||
Type: vt,
|
||||
Tag: reflect.StructTag(`db:"` + k + `" json:"` + k + `" form:"` + k + `"`),
|
||||
})
|
||||
}
|
||||
|
||||
st := reflect.StructOf(structFields)
|
||||
elem := reflect.New(st).Elem()
|
||||
|
||||
for i, v := range shapeValues {
|
||||
elem.Field(i).Set(v)
|
||||
}
|
||||
|
||||
return elem.Addr().Interface()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
package generated
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed types.d.ts
|
||||
var Types embed.FS
|
||||
+17513
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
// Package jsvm implements pluggable utilities for binding a JS goja runtime
|
||||
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// jsvm.MustRegister(app, jsvm.Config{
|
||||
// WatchHooks: true,
|
||||
// })
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/process"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/fatih/color"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm/internal/types/generated"
|
||||
"github.com/pocketbase/pocketbase/tools/template"
|
||||
)
|
||||
|
||||
const (
|
||||
typesFileName = "types.d.ts"
|
||||
)
|
||||
|
||||
// Config defines the config options of the jsvm plugin.
|
||||
type Config struct {
|
||||
// HooksWatch enables auto app restarts when a JS app hook file changes.
|
||||
//
|
||||
// Note that currently the application cannot be automatically restarted on Windows
|
||||
// because the restart process relies on execve.
|
||||
HooksWatch bool
|
||||
|
||||
// HooksDir specifies the JS app hooks directory.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_hooks" directory.
|
||||
HooksDir string
|
||||
|
||||
// HooksFilesPattern specifies a regular expression pattern that
|
||||
// identify which file to load by the hook vm(s).
|
||||
//
|
||||
// If not set it fallbacks to `^.*(\.pb\.js|\.pb\.ts)$`, aka. any
|
||||
// HookdsDir file ending in ".pb.js" or ".pb.ts" (the last one is to enforce IDE linters).
|
||||
HooksFilesPattern string
|
||||
|
||||
// HooksPoolSize specifies how many goja.Runtime instances to prewarm
|
||||
// and keep for the JS app hooks gorotines execution.
|
||||
//
|
||||
// Zero or negative value means that it will create a new goja.Runtime
|
||||
// on every fired goroutine.
|
||||
HooksPoolSize int
|
||||
|
||||
// MigrationsDir specifies the JS migrations directory.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
|
||||
MigrationsDir string
|
||||
|
||||
// If not set it fallbacks to `^.*(\.js|\.ts)$`, aka. any MigrationDir file
|
||||
// ending in ".js" or ".ts" (the last one is to enforce IDE linters).
|
||||
MigrationsFilesPattern string
|
||||
|
||||
// TypesDir specifies the directory where to store the embedded
|
||||
// TypeScript declarations file.
|
||||
//
|
||||
// If not set it fallbacks to "pb_data".
|
||||
TypesDir string
|
||||
}
|
||||
|
||||
// MustRegister registers the jsvm plugin in the provided app instance
|
||||
// and panics if it fails.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// jsvm.MustRegister(app, jsvm.Config{})
|
||||
func MustRegister(app core.App, config Config) {
|
||||
if err := Register(app, config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the jsvm plugin in the provided app instance.
|
||||
func Register(app core.App, config Config) error {
|
||||
p := &plugin{app: app, config: config}
|
||||
|
||||
if p.config.HooksDir == "" {
|
||||
p.config.HooksDir = filepath.Join(app.DataDir(), "../pb_hooks")
|
||||
}
|
||||
|
||||
if p.config.MigrationsDir == "" {
|
||||
p.config.MigrationsDir = filepath.Join(app.DataDir(), "../pb_migrations")
|
||||
}
|
||||
|
||||
if p.config.HooksFilesPattern == "" {
|
||||
p.config.HooksFilesPattern = `^.*(\.pb\.js|\.pb\.ts)$`
|
||||
}
|
||||
|
||||
if p.config.MigrationsFilesPattern == "" {
|
||||
p.config.MigrationsFilesPattern = `^.*(\.js|\.ts)$`
|
||||
}
|
||||
|
||||
if p.config.TypesDir == "" {
|
||||
p.config.TypesDir = app.DataDir()
|
||||
}
|
||||
|
||||
p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
// always update the app types on start to ensure that
|
||||
// the user has the latest generated declarations
|
||||
if err := p.saveTypesFile(); err != nil {
|
||||
color.Yellow("Unable to save app types file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := p.registerMigrations(); err != nil {
|
||||
return fmt.Errorf("registerMigrations: %w", err)
|
||||
}
|
||||
|
||||
if err := p.registerHooks(); err != nil {
|
||||
return fmt.Errorf("registerHooks: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
app core.App
|
||||
config Config
|
||||
}
|
||||
|
||||
// registerMigrations registers the JS migrations loader.
|
||||
func (p *plugin) registerMigrations() error {
|
||||
// fetch all js migrations sorted by their filename
|
||||
files, err := filesContent(p.config.MigrationsDir, p.config.MigrationsFilesPattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := new(require.Registry) // this can be shared by multiple runtimes
|
||||
|
||||
for file, content := range files {
|
||||
vm := goja.New()
|
||||
registry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
tokensBinds(vm)
|
||||
securityBinds(vm)
|
||||
// note: disallow for now and give the authors of custom SaaS offerings
|
||||
// some time to adjust their code to avoid eventual security issues
|
||||
//
|
||||
// osBinds(vm)
|
||||
// filepathBinds(vm)
|
||||
// httpClientBinds(vm)
|
||||
|
||||
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
|
||||
m.AppMigrations.Register(up, down, file)
|
||||
})
|
||||
|
||||
_, err := vm.RunString(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerHooks registers the JS app hooks loader.
|
||||
func (p *plugin) registerHooks() error {
|
||||
// fetch all js hooks sorted by their filename
|
||||
files, err := filesContent(p.config.HooksDir, p.config.HooksFilesPattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// prepend the types reference directive
|
||||
//
|
||||
// note: it is loaded during startup to handle conveniently also
|
||||
// the case when the HooksWatch option is enabled and the application
|
||||
// restart on newly created file
|
||||
for name, content := range files {
|
||||
if len(content) != 0 {
|
||||
// skip non-empty files for now to prevent accidental overwrite
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(p.config.HooksDir, name)
|
||||
directive := `/// <reference path="` + p.relativeTypesPath(p.config.HooksDir) + `" />`
|
||||
if err := prependToEmptyFile(path, directive+"\n\n"); err != nil {
|
||||
color.Yellow("Unable to prepend the types reference: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize the hooks dir watcher
|
||||
if p.config.HooksWatch {
|
||||
if err := p.watchHooks(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
// no need to register the vms since there are no entrypoint files anyway
|
||||
return nil
|
||||
}
|
||||
|
||||
absHooksDir, err := filepath.Abs(p.config.HooksDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
e.Router.HTTPErrorHandler = p.normalizeServeExceptions(e.Router.HTTPErrorHandler)
|
||||
return nil
|
||||
})
|
||||
|
||||
// safe to be shared across multiple vms
|
||||
requireRegistry := new(require.Registry)
|
||||
templateRegistry := template.NewRegistry()
|
||||
|
||||
sharedBinds := func(vm *goja.Runtime) {
|
||||
requireRegistry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
filesystemBinds(vm)
|
||||
tokensBinds(vm)
|
||||
securityBinds(vm)
|
||||
osBinds(vm)
|
||||
filepathBinds(vm)
|
||||
httpClientBinds(vm)
|
||||
formsBinds(vm)
|
||||
apisBinds(vm)
|
||||
vm.Set("$app", p.app)
|
||||
vm.Set("$template", templateRegistry)
|
||||
vm.Set("__hooks", absHooksDir)
|
||||
}
|
||||
|
||||
// initiliaze the executor vms
|
||||
executors := newPool(p.config.HooksPoolSize, func() *goja.Runtime {
|
||||
executor := goja.New()
|
||||
sharedBinds(executor)
|
||||
return executor
|
||||
})
|
||||
|
||||
// initialize the loader vm
|
||||
loader := goja.New()
|
||||
sharedBinds(loader)
|
||||
hooksBinds(p.app, loader, executors)
|
||||
cronBinds(p.app, loader, executors)
|
||||
routerBinds(p.app, loader, executors)
|
||||
|
||||
for file, content := range files {
|
||||
func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
fmtErr := fmt.Errorf("Failed to execute %s:\n - %v", file, err)
|
||||
|
||||
if p.config.HooksWatch {
|
||||
color.Red("%v", fmtErr)
|
||||
} else {
|
||||
panic(fmtErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := loader.RunString(string(content))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeExceptions wraps the provided error handler and returns a new one
|
||||
// with extracted goja exception error value for consistency when throwing or returning errors.
|
||||
func (p *plugin) normalizeServeExceptions(oldErrorHandler echo.HTTPErrorHandler) echo.HTTPErrorHandler {
|
||||
return func(c echo.Context, err error) {
|
||||
defer func() {
|
||||
oldErrorHandler(c, err)
|
||||
}()
|
||||
|
||||
if err == nil || c.Response().Committed {
|
||||
return // no error or already committed
|
||||
}
|
||||
|
||||
jsException, ok := err.(*goja.Exception)
|
||||
if !ok {
|
||||
return // 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchHooks initializes a hooks file watcher that will restart the
|
||||
// application (*if possible) in case of a change in the hooks directory.
|
||||
//
|
||||
// This method does nothing if the hooks directory is missing.
|
||||
func (p *plugin) watchHooks() error {
|
||||
if _, err := os.Stat(p.config.HooksDir); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil // no hooks dir to watch
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var debounceTimer *time.Timer
|
||||
|
||||
stopDebounceTimer := func() {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
debounceTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
p.app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
||||
watcher.Close()
|
||||
|
||||
stopDebounceTimer()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// start listening for events.
|
||||
go func() {
|
||||
defer stopDebounceTimer()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
stopDebounceTimer()
|
||||
|
||||
debounceTimer = time.AfterFunc(50*time.Millisecond, func() {
|
||||
// app restart is currently not supported on Windows
|
||||
if runtime.GOOS == "windows" {
|
||||
color.Yellow("File %s changed, please restart the app", event.Name)
|
||||
} else {
|
||||
color.Yellow("File %s changed, restarting...", event.Name)
|
||||
if err := p.app.Restart(); err != nil {
|
||||
color.Red("Failed to restart the app:", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
color.Red("Watch error:", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// add directories to watch
|
||||
//
|
||||
// @todo replace once recursive watcher is added (https://github.com/fsnotify/fsnotify/issues/18)
|
||||
dirsErr := filepath.Walk(p.config.HooksDir, func(path string, info fs.FileInfo, err error) error {
|
||||
// ignore hidden directories and node_modules
|
||||
if !info.IsDir() || info.Name() == "node_modules" || strings.HasPrefix(info.Name(), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return watcher.Add(path)
|
||||
})
|
||||
if dirsErr != nil {
|
||||
watcher.Close()
|
||||
}
|
||||
|
||||
return dirsErr
|
||||
}
|
||||
|
||||
// fullTypesPathReturns returns the full path to the generated TS file.
|
||||
func (p *plugin) fullTypesPath() string {
|
||||
return filepath.Join(p.config.TypesDir, typesFileName)
|
||||
}
|
||||
|
||||
// relativeTypesPath returns a path to the generated TS file relative
|
||||
// to the specified basepath.
|
||||
//
|
||||
// It fallbacks to the full path if generating the relative path fails.
|
||||
func (p *plugin) relativeTypesPath(basepath string) string {
|
||||
fullPath := p.fullTypesPath()
|
||||
|
||||
rel, err := filepath.Rel(basepath, fullPath)
|
||||
if err != nil {
|
||||
// fallback to the full path
|
||||
rel = fullPath
|
||||
}
|
||||
|
||||
return rel
|
||||
}
|
||||
|
||||
// saveTypesFile saves the embedded TS declarations as a file on the disk.
|
||||
func (p *plugin) saveTypesFile() error {
|
||||
fullPath := p.fullTypesPath()
|
||||
|
||||
// ensure that the types directory exists
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// retrieve the types data to write
|
||||
data, err := generated.Types.ReadFile(typesFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// prependToEmptyFile prepends the specified text to an empty file.
|
||||
//
|
||||
// If the file is not empty this method does nothing.
|
||||
func prependToEmptyFile(path, text string) error {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
if err == nil && info.Size() == 0 {
|
||||
return os.WriteFile(path, []byte(text), 0644)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// filesContent returns a map with all direct files within the specified dir and their content.
|
||||
//
|
||||
// If directory with dirPath is missing or no files matching the pattern were found,
|
||||
// it returns an empty map and no error.
|
||||
//
|
||||
// If pattern is empty string it matches all root files.
|
||||
func filesContent(dirPath string, pattern string) (map[string][]byte, error) {
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return map[string][]byte{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exp *regexp.Regexp
|
||||
if pattern != "" {
|
||||
var err error
|
||||
if exp, err = regexp.Compile(pattern); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string][]byte{}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() || (exp != nil && !exp.MatchString(f.Name())) {
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result[f.Name()] = raw
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var (
|
||||
_ goja.FieldNameMapper = (*FieldMapper)(nil)
|
||||
)
|
||||
|
||||
// FieldMapper provides custom mapping between Go and JavaScript property names.
|
||||
//
|
||||
// It is similar to the builtin "uncapFieldNameMapper" but also converts
|
||||
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
|
||||
type FieldMapper struct {
|
||||
}
|
||||
|
||||
// FieldName implements the [FieldNameMapper.FieldName] interface method.
|
||||
func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
|
||||
return convertGoToJSName(f.Name)
|
||||
}
|
||||
|
||||
// MethodName implements the [FieldNameMapper.MethodName] interface method.
|
||||
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
|
||||
return convertGoToJSName(m.Name)
|
||||
}
|
||||
|
||||
func convertGoToJSName(name string) string {
|
||||
startUppercase := make([]rune, 0, len(name))
|
||||
|
||||
for _, c := range name {
|
||||
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
|
||||
break
|
||||
}
|
||||
|
||||
startUppercase = append(startUppercase, c)
|
||||
}
|
||||
|
||||
totalStartUppercase := len(startUppercase)
|
||||
|
||||
// all uppercase eg. "JSON" -> "json"
|
||||
if len(name) == totalStartUppercase {
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// eg. "JSONField" -> "jsonField"
|
||||
if totalStartUppercase > 1 {
|
||||
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
|
||||
}
|
||||
|
||||
// eg. "GetField" -> "getField"
|
||||
if totalStartUppercase == 1 {
|
||||
return strings.ToLower(name[0:1]) + name[1:]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package jsvm_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
)
|
||||
|
||||
func TestFieldMapper(t *testing.T) {
|
||||
mapper := jsvm.FieldMapper{}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"test", "test"},
|
||||
{"Test", "test"},
|
||||
{"miXeD", "miXeD"},
|
||||
{"MiXeD", "miXeD"},
|
||||
{"ResolveRequestAsJSON", "resolveRequestAsJSON"},
|
||||
{"Variable_with_underscore", "variable_with_underscore"},
|
||||
{"ALLCAPS", "allcaps"},
|
||||
{"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"},
|
||||
{"OIDCMap", "oidcMap"},
|
||||
{"MD5", "md5"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
field := reflect.StructField{Name: s.name}
|
||||
if v := mapper.FieldName(nil, field); v != s.expected {
|
||||
t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
|
||||
method := reflect.Method{Name: s.name}
|
||||
if v := mapper.MethodName(nil, method); v != s.expected {
|
||||
t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja_nodejs/console"
|
||||
"github.com/dop251/goja_nodejs/process"
|
||||
"github.com/dop251/goja_nodejs/require"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// MigrationsOptions defines optional struct to customize the default migrations loader behavior.
|
||||
type MigrationsOptions struct {
|
||||
// Dir specifies the directory with the JS migrations.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
|
||||
Dir string
|
||||
}
|
||||
|
||||
// migrations is the migrations loader plugin definition.
|
||||
// Usually it is instantiated via RegisterMigrations or MustRegisterMigrations.
|
||||
type migrations struct {
|
||||
app core.App
|
||||
options *MigrationsOptions
|
||||
}
|
||||
|
||||
// MustRegisterMigrations registers the migrations loader plugin to
|
||||
// the provided app instance and panics if it fails.
|
||||
//
|
||||
// Internally it calls RegisterMigrations(app, options).
|
||||
//
|
||||
// If options is nil, by default the js files from pb_data/migrations are loaded.
|
||||
// Set custom options.Dir if you want to change it to some other directory.
|
||||
func MustRegisterMigrations(app core.App, options *MigrationsOptions) {
|
||||
if err := RegisterMigrations(app, options); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterMigrations registers the plugin to the provided app instance.
|
||||
//
|
||||
// If options is nil, by default the js files from pb_data/migrations are loaded.
|
||||
// Set custom options.Dir if you want to change it to some other directory.
|
||||
func RegisterMigrations(app core.App, options *MigrationsOptions) error {
|
||||
l := &migrations{app: app}
|
||||
|
||||
if options != nil {
|
||||
l.options = options
|
||||
} else {
|
||||
l.options = &MigrationsOptions{}
|
||||
}
|
||||
|
||||
if l.options.Dir == "" {
|
||||
l.options.Dir = filepath.Join(app.DataDir(), "../pb_migrations")
|
||||
}
|
||||
|
||||
files, err := readDirFiles(l.options.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := new(require.Registry) // this can be shared by multiple runtimes
|
||||
|
||||
for file, content := range files {
|
||||
vm := NewBaseVM()
|
||||
registry.Enable(vm)
|
||||
console.Enable(vm)
|
||||
process.Enable(vm)
|
||||
|
||||
vm.Set("migrate", func(up, down func(db dbx.Builder) error) {
|
||||
m.AppMigrations.Register(up, down, file)
|
||||
})
|
||||
|
||||
_, err := vm.RunString(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run migration %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readDirFiles returns a map with all directory files and their content.
|
||||
//
|
||||
// If directory with dirPath is missing, it returns an empty map and no error.
|
||||
func readDirFiles(dirPath string) (map[string][]byte, error) {
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string][]byte{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := map[string][]byte{}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() || !strings.HasSuffix(f.Name(), ".js") {
|
||||
continue // not a .js file
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dirPath, f.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[f.Name()] = raw
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type poolItem struct {
|
||||
mux sync.Mutex
|
||||
busy bool
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
type vmsPool struct {
|
||||
mux sync.RWMutex
|
||||
factory func() *goja.Runtime
|
||||
items []*poolItem
|
||||
}
|
||||
|
||||
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
|
||||
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
|
||||
pool := &vmsPool{
|
||||
factory: factory,
|
||||
items: make([]*poolItem, size),
|
||||
}
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
vm := pool.factory()
|
||||
pool.items[i] = &poolItem{vm: vm}
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
// run executes "call" with a vm created from the pool
|
||||
// (either from the buffer or a new one if all buffered vms are busy)
|
||||
func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
|
||||
p.mux.RLock()
|
||||
|
||||
// try to find a free item
|
||||
var freeItem *poolItem
|
||||
for _, item := range p.items {
|
||||
item.mux.Lock()
|
||||
if item.busy {
|
||||
item.mux.Unlock()
|
||||
continue
|
||||
}
|
||||
item.busy = true
|
||||
item.mux.Unlock()
|
||||
freeItem = item
|
||||
break
|
||||
}
|
||||
|
||||
p.mux.RUnlock()
|
||||
|
||||
// create a new one-off item if of all of the pool items are currently busy
|
||||
//
|
||||
// note: if turned out not efficient we may change this in the future
|
||||
// by adding the created item in the pool with some timer for removal
|
||||
if freeItem == nil {
|
||||
return call(p.factory())
|
||||
}
|
||||
|
||||
execErr := call(freeItem.vm)
|
||||
|
||||
// "free" the vm
|
||||
freeItem.mux.Lock()
|
||||
freeItem.busy = false
|
||||
freeItem.mux.Unlock()
|
||||
|
||||
return execErr
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// Package jsvm implements optional utilities for binding a JS goja runtime
|
||||
// to the PocketBase instance (loading migrations, attaching to app hooks, etc.).
|
||||
//
|
||||
// Currently it provides the following plugins:
|
||||
//
|
||||
// 1. JS Migrations loader:
|
||||
//
|
||||
// jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
// Dir: "custom_js_migrations_dir_path", // default to "pb_data/../pb_migrations"
|
||||
// })
|
||||
package jsvm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
func NewBaseVM() *goja.Runtime {
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(FieldMapper{})
|
||||
|
||||
baseBinds(vm)
|
||||
dbxBinds(vm)
|
||||
|
||||
return vm
|
||||
}
|
||||
|
||||
func baseBinds(vm *goja.Runtime) {
|
||||
vm.Set("unmarshal", func(src map[string]any, dest any) (any, error) {
|
||||
raw, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
})
|
||||
|
||||
vm.Set("Record", func(call goja.ConstructorCall) *goja.Object {
|
||||
var instance *models.Record
|
||||
|
||||
collection, ok := call.Argument(0).Export().(*models.Collection)
|
||||
if ok {
|
||||
instance = models.NewRecord(collection)
|
||||
data, ok := call.Argument(1).Export().(map[string]any)
|
||||
if ok {
|
||||
if raw, err := json.Marshal(data); err == nil {
|
||||
json.Unmarshal(raw, instance)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
instance = &models.Record{}
|
||||
}
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
|
||||
vm.Set("Collection", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &models.Collection{}
|
||||
return defaultConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Admin", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &models.Admin{}
|
||||
return defaultConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Schema", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &schema.Schema{}
|
||||
return defaultConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("SchemaField", func(call goja.ConstructorCall) *goja.Object {
|
||||
instance := &schema.SchemaField{}
|
||||
return defaultConstructor(vm, call, instance)
|
||||
})
|
||||
|
||||
vm.Set("Dao", func(call goja.ConstructorCall) *goja.Object {
|
||||
db, ok := call.Argument(0).Export().(dbx.Builder)
|
||||
if !ok || db == nil {
|
||||
panic("missing required Dao(db) argument")
|
||||
}
|
||||
|
||||
instance := daos.New(db)
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
})
|
||||
}
|
||||
|
||||
func defaultConstructor(vm *goja.Runtime, call goja.ConstructorCall, instance any) *goja.Object {
|
||||
if data := call.Argument(0).Export(); data != nil {
|
||||
if raw, err := json.Marshal(data); err == nil {
|
||||
json.Unmarshal(raw, instance)
|
||||
}
|
||||
}
|
||||
|
||||
instanceValue := vm.ToValue(instance).(*goja.Object)
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
|
||||
return instanceValue
|
||||
}
|
||||
|
||||
func dbxBinds(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$dbx", obj)
|
||||
|
||||
obj.Set("exp", dbx.NewExp)
|
||||
obj.Set("hashExp", func(data map[string]any) dbx.HashExp {
|
||||
exp := dbx.HashExp{}
|
||||
for k, v := range data {
|
||||
exp[k] = v
|
||||
}
|
||||
return exp
|
||||
})
|
||||
obj.Set("not", dbx.Not)
|
||||
obj.Set("and", dbx.And)
|
||||
obj.Set("or", dbx.Or)
|
||||
obj.Set("in", dbx.In)
|
||||
obj.Set("notIn", dbx.NotIn)
|
||||
obj.Set("like", dbx.Like)
|
||||
obj.Set("orLike", dbx.OrLike)
|
||||
obj.Set("notLike", dbx.NotLike)
|
||||
obj.Set("orNotLike", dbx.OrNotLike)
|
||||
obj.Set("exists", dbx.Exists)
|
||||
obj.Set("notExists", dbx.NotExists)
|
||||
obj.Set("between", dbx.Between)
|
||||
obj.Set("notBetween", dbx.NotBetween)
|
||||
}
|
||||
|
||||
func apisBind(vm *goja.Runtime) {
|
||||
obj := vm.NewObject()
|
||||
vm.Set("$apis", obj)
|
||||
|
||||
// middlewares
|
||||
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
|
||||
obj.Set("requireRecordAuth", apis.RequireRecordAuth)
|
||||
obj.Set("requireSameContextRecordAuth", apis.RequireSameContextRecordAuth)
|
||||
obj.Set("requireAdminAuth", apis.RequireAdminAuth)
|
||||
obj.Set("requireAdminAuthOnlyIfAny", apis.RequireAdminAuthOnlyIfAny)
|
||||
obj.Set("requireAdminOrRecordAuth", apis.RequireAdminOrRecordAuth)
|
||||
obj.Set("requireAdminOrOwnerAuth", apis.RequireAdminOrOwnerAuth)
|
||||
obj.Set("activityLogger", apis.ActivityLogger)
|
||||
|
||||
// api errors
|
||||
obj.Set("notFoundError", apis.NewNotFoundError)
|
||||
obj.Set("badRequestError", apis.NewBadRequestError)
|
||||
obj.Set("forbiddenError", apis.NewForbiddenError)
|
||||
obj.Set("unauthorizedError", apis.NewUnauthorizedError)
|
||||
|
||||
// record helpers
|
||||
obj.Set("requestData", apis.RequestData)
|
||||
obj.Set("enrichRecord", apis.EnrichRecord)
|
||||
obj.Set("enrichRecords", apis.EnrichRecords)
|
||||
}
|
||||
|
||||
// FieldMapper provides custom mapping between Go and JavaScript property names.
|
||||
//
|
||||
// It is similar to the builtin "uncapFieldNameMapper" but also converts
|
||||
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
|
||||
type FieldMapper struct {
|
||||
}
|
||||
|
||||
// FieldName implements the [FieldNameMapper.FieldName] interface method.
|
||||
func (u FieldMapper) FieldName(_ reflect.Type, f reflect.StructField) string {
|
||||
return convertGoToJSName(f.Name)
|
||||
}
|
||||
|
||||
// MethodName implements the [FieldNameMapper.MethodName] interface method.
|
||||
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
|
||||
return convertGoToJSName(m.Name)
|
||||
}
|
||||
|
||||
func convertGoToJSName(name string) string {
|
||||
allUppercase := true
|
||||
for _, c := range name {
|
||||
if c != '_' && !unicode.IsUpper(c) {
|
||||
allUppercase = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// eg. "JSON" -> "json"
|
||||
if allUppercase {
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// eg. "GetField" -> "getField"
|
||||
return strings.ToLower(name[0:1]) + name[1:]
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package jsvm_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestBaseVMUnmarshal(t *testing.T) {
|
||||
vm := jsvm.NewBaseVM()
|
||||
|
||||
v, err := vm.RunString(`unmarshal({ name: "test" }, new Collection())`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, ok := v.Export().(*models.Collection)
|
||||
if !ok {
|
||||
t.Fatalf("Expected models.Collection, got %v", m)
|
||||
}
|
||||
|
||||
if m.Name != "test" {
|
||||
t.Fatalf("Expected collection with name %q, got %q", "test", m.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseVMRecordBind(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vm := jsvm.NewBaseVM()
|
||||
vm.Set("collection", collection)
|
||||
|
||||
// without record data
|
||||
// ---
|
||||
v1, err := vm.RunString(`new Record(collection)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m1, ok := v1.Export().(*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected m1 to be models.Record, got \n%v", m1)
|
||||
}
|
||||
|
||||
// with record data
|
||||
// ---
|
||||
v2, err := vm.RunString(`new Record(collection, { email: "test@example.com" })`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m2, ok := v2.Export().(*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected m2 to be models.Record, got \n%v", m2)
|
||||
}
|
||||
|
||||
if m2.Collection().Name != "users" {
|
||||
t.Fatalf("Expected record with collection %q, got \n%v", "users", m2.Collection())
|
||||
}
|
||||
|
||||
if m2.Email() != "test@example.com" {
|
||||
t.Fatalf("Expected record with email field set to %q, got \n%v", "test@example.com", m2)
|
||||
}
|
||||
}
|
||||
|
||||
// @todo enable after https://github.com/dop251/goja/issues/426
|
||||
// func TestBaseVMRecordGetAndSetBind(t *testing.T) {
|
||||
// app, _ := tests.NewTestApp()
|
||||
// defer app.Cleanup()
|
||||
|
||||
// collection, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
|
||||
// vm := jsvm.NewBaseVM()
|
||||
// vm.Set("collection", collection)
|
||||
// vm.Set("getRecord", func() *models.Record {
|
||||
// return models.NewRecord(collection)
|
||||
// })
|
||||
|
||||
// _, runErr := vm.RunString(`
|
||||
// const jsRecord = new Record(collection);
|
||||
// jsRecord.email = "test@example.com"; // test js record setter
|
||||
// const email = jsRecord.email; // test js record getter
|
||||
|
||||
// const goRecord = getRecord()
|
||||
// goRecord.name = "test" // test go record setter
|
||||
// const name = goRecord.name; // test go record getter
|
||||
// `)
|
||||
// if runErr != nil {
|
||||
// t.Fatal(runErr)
|
||||
// }
|
||||
|
||||
// expectedEmail := "test@example.com"
|
||||
// expectedName := "test"
|
||||
|
||||
// jsRecord, ok := vm.Get("jsRecord").Export().(*models.Record)
|
||||
// if !ok {
|
||||
// t.Fatalf("Failed to export jsRecord")
|
||||
// }
|
||||
// if v := jsRecord.Email(); v != expectedEmail {
|
||||
// t.Fatalf("Expected the js created record to have email %q, got %q", expectedEmail, v)
|
||||
// }
|
||||
|
||||
// email := vm.Get("email").Export().(string)
|
||||
// if email != expectedEmail {
|
||||
// t.Fatalf("Expected exported email %q, got %q", expectedEmail, email)
|
||||
// }
|
||||
|
||||
// goRecord, ok := vm.Get("goRecord").Export().(*models.Record)
|
||||
// if !ok {
|
||||
// t.Fatalf("Failed to export goRecord")
|
||||
// }
|
||||
// if v := goRecord.GetString("name"); v != expectedName {
|
||||
// t.Fatalf("Expected the go created record to have name %q, got %q", expectedName, v)
|
||||
// }
|
||||
|
||||
// name := vm.Get("name").Export().(string)
|
||||
// if name != expectedName {
|
||||
// t.Fatalf("Expected exported name %q, got %q", expectedName, name)
|
||||
// }
|
||||
|
||||
// // ensure that the two record instances are not mixed
|
||||
// if v := goRecord.Email(); v != "" {
|
||||
// t.Fatalf("Expected the go created record to not have an email, got %q", v)
|
||||
// }
|
||||
// if v := jsRecord.GetString("name"); v != "" {
|
||||
// t.Fatalf("Expected the js created record to not have a name, got %q", v)
|
||||
// }
|
||||
// }
|
||||
|
||||
func TestBaseVMCollectionBind(t *testing.T) {
|
||||
vm := jsvm.NewBaseVM()
|
||||
|
||||
v, err := vm.RunString(`new Collection({ name: "test", schema: [{name: "title", "type": "text"}] })`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, ok := v.Export().(*models.Collection)
|
||||
if !ok {
|
||||
t.Fatalf("Expected models.Collection, got %v", m)
|
||||
}
|
||||
|
||||
if m.Name != "test" {
|
||||
t.Fatalf("Expected collection with name %q, got %q", "test", m.Name)
|
||||
}
|
||||
|
||||
if f := m.Schema.GetFieldByName("title"); f == nil {
|
||||
t.Fatalf("Expected schema to be set, got %v", m.Schema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseVMAdminBind(t *testing.T) {
|
||||
vm := jsvm.NewBaseVM()
|
||||
|
||||
v, err := vm.RunString(`new Admin({ email: "test@example.com" })`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, ok := v.Export().(*models.Admin)
|
||||
if !ok {
|
||||
t.Fatalf("Expected models.Admin, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseVMSchemaBind(t *testing.T) {
|
||||
vm := jsvm.NewBaseVM()
|
||||
|
||||
v, err := vm.RunString(`new Schema([{name: "title", "type": "text"}])`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, ok := v.Export().(*schema.Schema)
|
||||
if !ok {
|
||||
t.Fatalf("Expected schema.Schema, got %v", m)
|
||||
}
|
||||
|
||||
if f := m.GetFieldByName("title"); f == nil {
|
||||
t.Fatalf("Expected schema fields to be loaded, got %v", m.Fields())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseVMSchemaFieldBind(t *testing.T) {
|
||||
vm := jsvm.NewBaseVM()
|
||||
|
||||
v, err := vm.RunString(`new SchemaField({name: "title", "type": "text"})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, ok := v.Export().(*schema.SchemaField)
|
||||
if !ok {
|
||||
t.Fatalf("Expected schema.SchemaField, got %v", f)
|
||||
}
|
||||
|
||||
if f.Name != "title" {
|
||||
t.Fatalf("Expected field %q, got %v", "title", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseVMDaoBind(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
vm := jsvm.NewBaseVM()
|
||||
vm.Set("db", app.DB())
|
||||
|
||||
v, err := vm.RunString(`new Dao(db)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d, ok := v.Export().(*daos.Dao)
|
||||
if !ok {
|
||||
t.Fatalf("Expected daos.Dao, got %v", d)
|
||||
}
|
||||
|
||||
if d.DB() != app.DB() {
|
||||
t.Fatalf("The db instances doesn't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldMapper(t *testing.T) {
|
||||
mapper := jsvm.FieldMapper{}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"test", "test"},
|
||||
{"Test", "test"},
|
||||
{"miXeD", "miXeD"},
|
||||
{"MiXeD", "miXeD"},
|
||||
{"ResolveRequestAsJSON", "resolveRequestAsJSON"},
|
||||
{"Variable_with_underscore", "variable_with_underscore"},
|
||||
{"ALLCAPS", "allcaps"},
|
||||
{"NOTALLCAPs", "nOTALLCAPs"},
|
||||
{"ALL_CAPS_WITH_UNDERSCORE", "all_caps_with_underscore"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
field := reflect.StructField{Name: s.name}
|
||||
if v := mapper.FieldName(nil, field); v != s.expected {
|
||||
t.Fatalf("[%d] Expected FieldName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
|
||||
method := reflect.Method{Name: s.name}
|
||||
if v := mapper.MethodName(nil, method); v != s.expected {
|
||||
t.Fatalf("[%d] Expected MethodName %q, got %q", i, s.expected, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error {
|
||||
|
||||
var template string
|
||||
var templateErr error
|
||||
if p.options.TemplateLang == TemplateLangJS {
|
||||
if p.config.TemplateLang == TemplateLangJS {
|
||||
template, templateErr = p.jsDiffTemplate(new, old)
|
||||
} else {
|
||||
template, templateErr = p.goDiffTemplate(new, old)
|
||||
@@ -62,22 +62,23 @@ func (p *plugin) afterCollectionChange() func(*core.ModelEvent) error {
|
||||
action = "updated_" + old.Name
|
||||
}
|
||||
|
||||
appliedTime := time.Now().Unix()
|
||||
name := fmt.Sprintf("%d_%s.%s", appliedTime, action, p.options.TemplateLang)
|
||||
filePath := filepath.Join(p.options.Dir, name)
|
||||
name := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), action, p.config.TemplateLang)
|
||||
filePath := filepath.Join(p.config.Dir, name)
|
||||
|
||||
return p.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// insert the migration entry
|
||||
_, err := txDao.DB().Insert(migrate.DefaultMigrationsTable, dbx.Params{
|
||||
"file": name,
|
||||
"applied": appliedTime,
|
||||
"file": name,
|
||||
// use microseconds for more granular applied time in case
|
||||
// multiple collection changes happens at the ~exact time
|
||||
"applied": time.Now().UnixMicro(),
|
||||
}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure that the local migrations dir exist
|
||||
if err := os.MkdirAll(p.options.Dir, os.ModePerm); err != nil {
|
||||
if err := os.MkdirAll(p.config.Dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create migration dir: %w", err)
|
||||
}
|
||||
|
||||
@@ -136,19 +137,3 @@ func (p *plugin) getCachedCollections() (map[string]*models.Collection, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *plugin) hasCustomMigrations() bool {
|
||||
files, err := os.ReadDir(p.options.Dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
// TemplateLang: migratecmd.TemplateLangJS, // default to migratecmd.TemplateLangGo
|
||||
// Automigrate: true,
|
||||
// Dir: "migrations_dir_path", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go)
|
||||
// Dir: "/custom/migrations/dir", // optional template migrations path; default to "pb_migrations" (for JS) and "migrations" (for Go)
|
||||
// })
|
||||
//
|
||||
// Note: To allow running JS migrations you'll need to enable first
|
||||
// [jsvm.MustRegisterMigrations].
|
||||
// [jsvm.MustRegister()].
|
||||
package migratecmd
|
||||
|
||||
import (
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -32,8 +31,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Options defines optional struct to customize the default plugin behavior.
|
||||
type Options struct {
|
||||
// Config defines the config options of the migratecmd plugin.
|
||||
type Config struct {
|
||||
// Dir specifies the directory with the user defined migrations.
|
||||
//
|
||||
// If not set it fallbacks to a relative "pb_data/../pb_migrations" (for js)
|
||||
@@ -48,35 +47,31 @@ type Options struct {
|
||||
TemplateLang string
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
app core.App
|
||||
options *Options
|
||||
}
|
||||
|
||||
func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) {
|
||||
if err := Register(app, rootCmd, options); err != nil {
|
||||
// MustRegister registers the migratecmd plugin to the provided app instance
|
||||
// and panic if it fails.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{})
|
||||
func MustRegister(app core.App, rootCmd *cobra.Command, config Config) {
|
||||
if err := Register(app, rootCmd, config); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
p := &plugin{app: app}
|
||||
// Register registers the migratecmd plugin to the provided app instance.
|
||||
func Register(app core.App, rootCmd *cobra.Command, config Config) error {
|
||||
p := &plugin{app: app, config: config}
|
||||
|
||||
if options != nil {
|
||||
p.options = options
|
||||
} else {
|
||||
p.options = &Options{}
|
||||
if p.config.TemplateLang == "" {
|
||||
p.config.TemplateLang = TemplateLangGo
|
||||
}
|
||||
|
||||
if p.options.TemplateLang == "" {
|
||||
p.options.TemplateLang = TemplateLangGo
|
||||
}
|
||||
|
||||
if p.options.Dir == "" {
|
||||
if p.options.TemplateLang == TemplateLangJS {
|
||||
p.options.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations")
|
||||
if p.config.Dir == "" {
|
||||
if p.config.TemplateLang == TemplateLangJS {
|
||||
p.config.Dir = filepath.Join(p.app.DataDir(), "../pb_migrations")
|
||||
} else {
|
||||
p.options.Dir = filepath.Join(p.app.DataDir(), "../migrations")
|
||||
p.config.Dir = filepath.Join(p.app.DataDir(), "../migrations")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +81,7 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
}
|
||||
|
||||
// watch for collection changes
|
||||
if p.options.Automigrate {
|
||||
if p.config.Automigrate {
|
||||
// refresh the cache right after app bootstap
|
||||
p.app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
p.refreshCachedCollections()
|
||||
@@ -97,27 +92,6 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
// when migrations are applied on server start
|
||||
p.app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
p.refreshCachedCollections()
|
||||
|
||||
cachedCollections, _ := p.getCachedCollections()
|
||||
// create a full initial snapshot, if there are no custom
|
||||
// migrations but there is already at least 1 collection created,
|
||||
// to ensure that the automigrate will work with up-to-date collections data
|
||||
if !p.hasCustomMigrations() && len(cachedCollections) > 1 {
|
||||
snapshotFile, err := p.migrateCollectionsHandler(nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert the snapshot migration entry
|
||||
_, insertErr := p.app.Dao().NonconcurrentDB().Insert(migrate.DefaultMigrationsTable, dbx.Params{
|
||||
"file": snapshotFile,
|
||||
"applied": time.Now().Unix(),
|
||||
}).Execute()
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -129,6 +103,11 @@ func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
app core.App
|
||||
config Config
|
||||
}
|
||||
|
||||
func (p *plugin) createCommand() *cobra.Command {
|
||||
const cmdDesc = `Supported arguments are:
|
||||
- up - runs all available migrations
|
||||
@@ -185,9 +164,9 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
dir := p.options.Dir
|
||||
dir := p.config.Dir
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.options.TemplateLang)
|
||||
filename := fmt.Sprintf("%d_%s.%s", time.Now().Unix(), inflector.Snakecase(name), p.config.TemplateLang)
|
||||
|
||||
resultFilePath := path.Join(dir, filename)
|
||||
|
||||
@@ -206,7 +185,7 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv
|
||||
// get default create template
|
||||
if template == "" {
|
||||
var templateErr error
|
||||
if p.options.TemplateLang == TemplateLangJS {
|
||||
if p.config.TemplateLang == TemplateLangJS {
|
||||
template, templateErr = p.jsBlankTemplate()
|
||||
} else {
|
||||
template, templateErr = p.goBlankTemplate()
|
||||
@@ -244,7 +223,7 @@ func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (str
|
||||
|
||||
var template string
|
||||
var templateErr error
|
||||
if p.options.TemplateLang == TemplateLangJS {
|
||||
if p.config.TemplateLang == TemplateLangJS {
|
||||
template, templateErr = p.jsSnapshotTemplate(collections)
|
||||
} else {
|
||||
template, templateErr = p.goSnapshotTemplate(collections)
|
||||
|
||||
@@ -6,14 +6,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -25,6 +22,7 @@ func TestAutomigrateCollectionCreate(t *testing.T) {
|
||||
{
|
||||
migratecmd.TemplateLangJS,
|
||||
`
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "new_id",
|
||||
@@ -134,7 +132,7 @@ func init() {
|
||||
|
||||
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
|
||||
|
||||
migratecmd.MustRegister(app, nil, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, nil, migratecmd.Config{
|
||||
TemplateLang: s.lang,
|
||||
Automigrate: true,
|
||||
Dir: migrationsDir,
|
||||
@@ -197,6 +195,7 @@ func TestAutomigrateCollectionDelete(t *testing.T) {
|
||||
{
|
||||
migratecmd.TemplateLangJS,
|
||||
`
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("test123");
|
||||
@@ -306,7 +305,7 @@ func init() {
|
||||
|
||||
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
|
||||
|
||||
migratecmd.MustRegister(app, nil, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, nil, migratecmd.Config{
|
||||
TemplateLang: s.lang,
|
||||
Automigrate: true,
|
||||
Dir: migrationsDir,
|
||||
@@ -375,6 +374,7 @@ func TestAutomigrateCollectionUpdate(t *testing.T) {
|
||||
{
|
||||
migratecmd.TemplateLangJS,
|
||||
`
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("test123")
|
||||
@@ -630,7 +630,7 @@ func init() {
|
||||
|
||||
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
|
||||
|
||||
migratecmd.MustRegister(app, nil, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, nil, migratecmd.Config{
|
||||
TemplateLang: s.lang,
|
||||
Automigrate: true,
|
||||
Dir: migrationsDir,
|
||||
@@ -749,7 +749,7 @@ func TestAutomigrateCollectionNoChanges(t *testing.T) {
|
||||
|
||||
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
|
||||
|
||||
migratecmd.MustRegister(app, nil, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, nil, migratecmd.Config{
|
||||
TemplateLang: s.lang,
|
||||
Automigrate: true,
|
||||
Dir: migrationsDir,
|
||||
@@ -779,36 +779,3 @@ func TestAutomigrateCollectionNoChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialAutoSnapshot(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
migrationsDir := filepath.Join(app.DataDir(), "_test_auto_snapshot_")
|
||||
|
||||
migratecmd.MustRegister(app, nil, &migratecmd.Options{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: true,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.Bootstrap()
|
||||
|
||||
app.OnBeforeServe().Trigger(&core.ServeEvent{
|
||||
App: app,
|
||||
})
|
||||
|
||||
var foundFiles []string
|
||||
|
||||
err := app.Dao().NonconcurrentDB().Select("file").
|
||||
From(migrate.DefaultMigrationsTable).
|
||||
Where(dbx.NewExp("file like '%collections_snapshot.js'")).
|
||||
Column(&foundFiles)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(foundFiles) != 1 {
|
||||
t.Fatalf("Expected 1 collections_snapshot migration, found %v", foundFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
const (
|
||||
TemplateLangJS = "js"
|
||||
TemplateLangGo = "go"
|
||||
|
||||
// note: this usually should be configurable similar to the jsvm plugin,
|
||||
// but for simplicity is static as users can easily change the
|
||||
// reference path if they use custom dirs structure
|
||||
jsTypesDirective = `/// <reference path="../pb_data/types.d.ts" />` + "\n"
|
||||
)
|
||||
|
||||
var emptyTemplateErr = errors.New("empty template")
|
||||
@@ -24,7 +29,7 @@ var emptyTemplateErr = errors.New("empty template")
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (p *plugin) jsBlankTemplate() (string, error) {
|
||||
const template = `migrate((db) => {
|
||||
const template = jsTypesDirective + `migrate((db) => {
|
||||
// add up queries...
|
||||
}, (db) => {
|
||||
// add down queries...
|
||||
@@ -40,7 +45,7 @@ func (p *plugin) jsSnapshotTemplate(collections []*models.Collection) (string, e
|
||||
return "", fmt.Errorf("failed to serialize collections list: %w", err)
|
||||
}
|
||||
|
||||
const template = `migrate((db) => {
|
||||
const template = jsTypesDirective + `migrate((db) => {
|
||||
const snapshot = %s;
|
||||
|
||||
const collections = snapshot.map((item) => new Collection(item));
|
||||
@@ -60,7 +65,7 @@ func (p *plugin) jsCreateTemplate(collection *models.Collection) (string, error)
|
||||
return "", fmt.Errorf("failed to serialize collections list: %w", err)
|
||||
}
|
||||
|
||||
const template = `migrate((db) => {
|
||||
const template = jsTypesDirective + `migrate((db) => {
|
||||
const collection = new Collection(%s);
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
@@ -81,7 +86,7 @@ func (p *plugin) jsDeleteTemplate(collection *models.Collection) (string, error)
|
||||
return "", fmt.Errorf("failed to serialize collections list: %w", err)
|
||||
}
|
||||
|
||||
const template = `migrate((db) => {
|
||||
const template = jsTypesDirective + `migrate((db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId(%q);
|
||||
|
||||
@@ -294,7 +299,7 @@ func (p *plugin) jsDiffTemplate(new *models.Collection, old *models.Collection)
|
||||
up := strings.Join(upParts, "\n ")
|
||||
down := strings.Join(downParts, "\n ")
|
||||
|
||||
const template = `migrate((db) => {
|
||||
const template = jsTypesDirective + `migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId(%q)
|
||||
|
||||
@@ -343,7 +348,7 @@ func init() {
|
||||
}
|
||||
`
|
||||
|
||||
return fmt.Sprintf(template, filepath.Base(p.options.Dir)), nil
|
||||
return fmt.Sprintf(template, filepath.Base(p.config.Dir)), nil
|
||||
}
|
||||
|
||||
func (p *plugin) goSnapshotTemplate(collections []*models.Collection) (string, error) {
|
||||
@@ -380,7 +385,7 @@ func init() {
|
||||
`
|
||||
return fmt.Sprintf(
|
||||
template,
|
||||
filepath.Base(p.options.Dir),
|
||||
filepath.Base(p.config.Dir),
|
||||
escapeBacktick(string(jsonData)),
|
||||
), nil
|
||||
}
|
||||
@@ -427,7 +432,7 @@ func init() {
|
||||
|
||||
return fmt.Sprintf(
|
||||
template,
|
||||
filepath.Base(p.options.Dir),
|
||||
filepath.Base(p.config.Dir),
|
||||
escapeBacktick(string(jsonData)),
|
||||
collection.Id,
|
||||
), nil
|
||||
@@ -475,7 +480,7 @@ func init() {
|
||||
|
||||
return fmt.Sprintf(
|
||||
template,
|
||||
filepath.Base(p.options.Dir),
|
||||
filepath.Base(p.config.Dir),
|
||||
collection.Id,
|
||||
escapeBacktick(string(jsonData)),
|
||||
), nil
|
||||
@@ -745,7 +750,7 @@ func init() {
|
||||
|
||||
return fmt.Sprintf(
|
||||
template,
|
||||
filepath.Base(p.options.Dir),
|
||||
filepath.Base(p.config.Dir),
|
||||
imports,
|
||||
old.Id, strings.TrimSpace(up),
|
||||
new.Id, strings.TrimSpace(down),
|
||||
|
||||
+5
-8
@@ -68,7 +68,7 @@ type Config struct {
|
||||
func New() *PocketBase {
|
||||
_, isUsingGoRun := inspectRuntime()
|
||||
|
||||
return NewWithConfig(&Config{
|
||||
return NewWithConfig(Config{
|
||||
DefaultDebug: isUsingGoRun,
|
||||
})
|
||||
}
|
||||
@@ -80,11 +80,7 @@ func New() *PocketBase {
|
||||
// Everything will be initialized when [Start()] is executed.
|
||||
// If you want to initialize the application before calling [Start()],
|
||||
// then you'll have to manually call [Bootstrap()].
|
||||
func NewWithConfig(config *Config) *PocketBase {
|
||||
if config == nil {
|
||||
panic("missing config")
|
||||
}
|
||||
|
||||
func NewWithConfig(config Config) *PocketBase {
|
||||
// initialize a default data directory based on the executable baseDir
|
||||
if config.DefaultDataDir == "" {
|
||||
baseDir, _ := inspectRuntime()
|
||||
@@ -112,10 +108,10 @@ func NewWithConfig(config *Config) *PocketBase {
|
||||
|
||||
// parse base flags
|
||||
// (errors are ignored, since the full flags parsing happens on Execute())
|
||||
pb.eagerParseFlags(config)
|
||||
pb.eagerParseFlags(&config)
|
||||
|
||||
// initialize the app instance
|
||||
pb.appWrapper = &appWrapper{core.NewBaseApp(&core.BaseAppConfig{
|
||||
pb.appWrapper = &appWrapper{core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: pb.dataDirFlag,
|
||||
EncryptionEnv: pb.encryptionEnvFlag,
|
||||
IsDebug: pb.debugFlag,
|
||||
@@ -167,6 +163,7 @@ func (pb *PocketBase) Execute() error {
|
||||
go func() {
|
||||
if err := pb.RootCmd.Execute(); err != nil {
|
||||
// @todo replace with db log once generalized logs are added
|
||||
// and maybe consider reorganizing the code to return os.Exit(1)
|
||||
// (note may need to update the existing commands to not silence errors)
|
||||
color.Red(err.Error())
|
||||
}
|
||||
|
||||
+8
-8
@@ -54,7 +54,7 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewWithConfig(t *testing.T) {
|
||||
app := NewWithConfig(&Config{
|
||||
app := NewWithConfig(Config{
|
||||
DefaultDebug: true,
|
||||
DefaultDataDir: "test_dir",
|
||||
DefaultEncryptionEnv: "test_encryption_env",
|
||||
@@ -108,7 +108,7 @@ func TestNewWithConfigAndFlags(t *testing.T) {
|
||||
"--debug=false",
|
||||
)
|
||||
|
||||
app := NewWithConfig(&Config{
|
||||
app := NewWithConfig(Config{
|
||||
DefaultDebug: true,
|
||||
DefaultDataDir: "test_dir",
|
||||
DefaultEncryptionEnv: "test_encryption_env",
|
||||
@@ -157,7 +157,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// already bootstrapped
|
||||
app0 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app0 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
app0.Bootstrap()
|
||||
if v := app0.skipBootstrap(); !v {
|
||||
t.Fatal("[bootstrapped] Expected true, got false")
|
||||
@@ -166,7 +166,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
// unknown command
|
||||
os.Args = os.Args[:1]
|
||||
os.Args = append(os.Args, "demo")
|
||||
app1 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app1 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
app1.RootCmd.AddCommand(&cobra.Command{Use: "test"})
|
||||
if v := app1.skipBootstrap(); !v {
|
||||
t.Fatal("[unknown] Expected true, got false")
|
||||
@@ -185,7 +185,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
// base flag
|
||||
os.Args = os.Args[:1]
|
||||
os.Args = append(os.Args, "--"+s.name)
|
||||
app1 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app1 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
if v := app1.skipBootstrap(); !v {
|
||||
t.Fatalf("[--%s] Expected true, got false", s.name)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
// short flag
|
||||
os.Args = os.Args[:1]
|
||||
os.Args = append(os.Args, "-"+s.short)
|
||||
app2 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app2 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
if v := app2.skipBootstrap(); !v {
|
||||
t.Fatalf("[-%s] Expected true, got false", s.short)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
os.Args = os.Args[:1]
|
||||
os.Args = append(os.Args, "custom")
|
||||
os.Args = append(os.Args, "--"+s.name)
|
||||
app3 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app3 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
app3.RootCmd.AddCommand(customCmd)
|
||||
if v := app3.skipBootstrap(); v {
|
||||
t.Fatalf("[--%s custom] Expected false, got true", s.name)
|
||||
@@ -215,7 +215,7 @@ func TestSkipBootstrap(t *testing.T) {
|
||||
os.Args = os.Args[:1]
|
||||
os.Args = append(os.Args, "custom")
|
||||
os.Args = append(os.Args, "-"+s.short)
|
||||
app4 := NewWithConfig(&Config{DefaultDataDir: tempDir})
|
||||
app4 := NewWithConfig(Config{DefaultDataDir: tempDir})
|
||||
app4.RootCmd.AddCommand(customCmd)
|
||||
if v := app4.skipBootstrap(); v {
|
||||
t.Fatalf("[-%s custom] Expected false, got true", s.short)
|
||||
|
||||
@@ -64,7 +64,7 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
}
|
||||
|
||||
if r.activeProps[0] == "@request" {
|
||||
if r.resolver.requestData == nil {
|
||||
if r.resolver.requestInfo == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
@@ -87,17 +87,17 @@ func (r *runner) run() (*search.ResolverResult, error) {
|
||||
|
||||
// check for data relation field
|
||||
if dataField.Type == schema.FieldTypeRelation && len(r.activeProps) > 3 {
|
||||
return r.processRequestDataRelationField(dataField)
|
||||
return r.processRequestInfoRelationField(dataField)
|
||||
}
|
||||
|
||||
// check for select:each field
|
||||
if modifier == eachModifier && dataField.Type == schema.FieldTypeSelect && len(r.activeProps) == 3 {
|
||||
return r.processRequestDataSelectEachModifier(dataField)
|
||||
return r.processRequestInfoSelectEachModifier(dataField)
|
||||
}
|
||||
|
||||
// check for data arrayble fields ":length" modifier
|
||||
if modifier == lengthModifier && list.ExistInSlice(dataField.Type, schema.ArraybleFieldTypes()) && len(r.activeProps) == 3 {
|
||||
return r.processRequestDataLengthModifier(dataField)
|
||||
return r.processRequestInfoLengthModifier(dataField)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,11 +177,11 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
|
||||
// resolve the auth collection field
|
||||
// ---
|
||||
if r.resolver.requestData == nil || r.resolver.requestData.AuthRecord == nil || r.resolver.requestData.AuthRecord.Collection() == nil {
|
||||
if r.resolver.requestInfo == nil || r.resolver.requestInfo.AuthRecord == nil || r.resolver.requestInfo.AuthRecord.Collection() == nil {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
}
|
||||
|
||||
collection := r.resolver.requestData.AuthRecord.Collection()
|
||||
collection := r.resolver.requestInfo.AuthRecord.Collection()
|
||||
r.resolver.loadedCollections = append(r.resolver.loadedCollections, collection)
|
||||
|
||||
r.activeCollectionName = collection.Name
|
||||
@@ -193,7 +193,7 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
r.activeTableAlias,
|
||||
dbx.HashExp{
|
||||
// aka. __auth_users.id = :userId
|
||||
(r.activeTableAlias + ".id"): r.resolver.requestData.AuthRecord.Id,
|
||||
(r.activeTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -205,7 +205,7 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
tableName: inflector.Columnify(r.activeCollectionName),
|
||||
tableAlias: r.multiMatchActiveTableAlias,
|
||||
on: dbx.HashExp{
|
||||
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestData.AuthRecord.Id,
|
||||
(r.multiMatchActiveTableAlias + ".id"): r.resolver.requestInfo.AuthRecord.Id,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -217,8 +217,8 @@ func (r *runner) processRequestAuthField() (*search.ResolverResult, error) {
|
||||
return r.processActiveProps()
|
||||
}
|
||||
|
||||
func (r *runner) processRequestDataLengthModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
dataItems := list.ToUniqueStringSlice(r.resolver.requestData.Data[dataField.Name])
|
||||
func (r *runner) processRequestInfoLengthModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
|
||||
|
||||
result := &search.ResolverResult{
|
||||
Identifier: fmt.Sprintf("%d", len(dataItems)),
|
||||
@@ -227,13 +227,13 @@ func (r *runner) processRequestDataLengthModifier(dataField *schema.SchemaField)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestDataSelectEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestInfoSelectEachModifier(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
options, ok := dataField.Options.(*schema.SelectOptions)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to initialize field %q options", dataField.Name)
|
||||
}
|
||||
|
||||
dataItems := list.ToUniqueStringSlice(r.resolver.requestData.Data[dataField.Name])
|
||||
dataItems := list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
|
||||
rawJson, err := json.Marshal(dataItems)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot marshalize the data select item for field %q", r.activeProps[2])
|
||||
@@ -272,7 +272,7 @@ func (r *runner) processRequestDataSelectEachModifier(dataField *schema.SchemaFi
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *runner) processRequestDataRelationField(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
func (r *runner) processRequestInfoRelationField(dataField *schema.SchemaField) (*search.ResolverResult, error) {
|
||||
options, ok := dataField.Options.(*schema.RelationOptions)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to initialize data field %q options", dataField.Name)
|
||||
@@ -284,8 +284,8 @@ func (r *runner) processRequestDataRelationField(dataField *schema.SchemaField)
|
||||
}
|
||||
|
||||
var dataRelIds []string
|
||||
if r.resolver.requestData != nil && len(r.resolver.requestData.Data) != 0 {
|
||||
dataRelIds = list.ToUniqueStringSlice(r.resolver.requestData.Data[dataField.Name])
|
||||
if r.resolver.requestInfo != nil && len(r.resolver.requestInfo.Data) != 0 {
|
||||
dataRelIds = list.ToUniqueStringSlice(r.resolver.requestInfo.Data[dataField.Name])
|
||||
}
|
||||
if len(dataRelIds) == 0 {
|
||||
return &search.ResolverResult{Identifier: "NULL"}, nil
|
||||
@@ -580,8 +580,8 @@ func (r *runner) processActiveProps() (*search.ResolverResult, error) {
|
||||
func jsonArrayLength(tableColumnPair string) string {
|
||||
return fmt.Sprintf(
|
||||
// note: the case is used to normalize value access for single and multiple relations.
|
||||
`json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END)`,
|
||||
tableColumnPair, tableColumnPair, tableColumnPair,
|
||||
`json_array_length(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE (CASE WHEN [[%s]] = '' OR [[%s]] IS NULL THEN json_array() ELSE json_array([[%s]]) END) END)`,
|
||||
tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair, tableColumnPair,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
@@ -39,6 +38,15 @@ var plainRequestAuthFields = []string{
|
||||
// ensure that `search.FieldResolver` interface is implemented
|
||||
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
|
||||
// CollectionsFinder defines a common interface for retrieving
|
||||
// collections and other related models.
|
||||
//
|
||||
// The interface at the moment is primarily used to avoid circular
|
||||
// dependency with the daos.Dao package.
|
||||
type CollectionsFinder interface {
|
||||
FindCollectionByNameOrId(collectionNameOrId string) (*models.Collection, error)
|
||||
}
|
||||
|
||||
// RecordFieldResolver defines a custom search resolver struct for
|
||||
// managing Record model search fields.
|
||||
//
|
||||
@@ -48,33 +56,33 @@ var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
// resolver := resolvers.NewRecordFieldResolver(
|
||||
// app.Dao(),
|
||||
// myCollection,
|
||||
// &models.RequestData{...},
|
||||
// &models.RequestInfo{...},
|
||||
// true,
|
||||
// )
|
||||
// provider := search.NewProvider(resolver)
|
||||
// ...
|
||||
type RecordFieldResolver struct {
|
||||
dao *daos.Dao
|
||||
dao CollectionsFinder
|
||||
baseCollection *models.Collection
|
||||
allowHiddenFields bool
|
||||
allowedFields []string
|
||||
loadedCollections []*models.Collection
|
||||
joins []*join // we cannot use a map because the insertion order is not preserved
|
||||
requestData *models.RequestData
|
||||
staticRequestData map[string]any
|
||||
requestInfo *models.RequestInfo
|
||||
staticRequestInfo map[string]any
|
||||
}
|
||||
|
||||
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
|
||||
func NewRecordFieldResolver(
|
||||
dao *daos.Dao,
|
||||
dao CollectionsFinder,
|
||||
baseCollection *models.Collection,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
allowHiddenFields bool,
|
||||
) *RecordFieldResolver {
|
||||
r := &RecordFieldResolver{
|
||||
dao: dao,
|
||||
baseCollection: baseCollection,
|
||||
requestData: requestData,
|
||||
requestInfo: requestInfo,
|
||||
allowHiddenFields: allowHiddenFields,
|
||||
joins: []*join{},
|
||||
loadedCollections: []*models.Collection{baseCollection},
|
||||
@@ -89,17 +97,17 @@ func NewRecordFieldResolver(
|
||||
},
|
||||
}
|
||||
|
||||
r.staticRequestData = map[string]any{}
|
||||
if r.requestData != nil {
|
||||
r.staticRequestData["method"] = r.requestData.Method
|
||||
r.staticRequestData["query"] = r.requestData.Query
|
||||
r.staticRequestData["headers"] = r.requestData.Headers
|
||||
r.staticRequestData["data"] = r.requestData.Data
|
||||
r.staticRequestData["auth"] = nil
|
||||
if r.requestData.AuthRecord != nil {
|
||||
r.requestData.AuthRecord.IgnoreEmailVisibility(true)
|
||||
r.staticRequestData["auth"] = r.requestData.AuthRecord.PublicExport()
|
||||
r.requestData.AuthRecord.IgnoreEmailVisibility(false)
|
||||
r.staticRequestInfo = map[string]any{}
|
||||
if r.requestInfo != nil {
|
||||
r.staticRequestInfo["method"] = r.requestInfo.Method
|
||||
r.staticRequestInfo["query"] = r.requestInfo.Query
|
||||
r.staticRequestInfo["headers"] = r.requestInfo.Headers
|
||||
r.staticRequestInfo["data"] = r.requestInfo.Data
|
||||
r.staticRequestInfo["auth"] = nil
|
||||
if r.requestInfo.AuthRecord != nil {
|
||||
r.requestInfo.AuthRecord.IgnoreEmailVisibility(true)
|
||||
r.staticRequestInfo["auth"] = r.requestInfo.AuthRecord.PublicExport()
|
||||
r.requestInfo.AuthRecord.IgnoreEmailVisibility(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +166,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
path[len(path)-1] = lastProp
|
||||
|
||||
// extract value
|
||||
resultVal, err := extractNestedMapVal(r.staticRequestData, path...)
|
||||
resultVal, err := extractNestedMapVal(r.staticRequestInfo, path...)
|
||||
|
||||
if modifier == issetModifier {
|
||||
if err != nil {
|
||||
@@ -167,7 +175,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
|
||||
return &search.ResolverResult{Identifier: "TRUE"}, nil
|
||||
}
|
||||
|
||||
// note: we are ignoring the error because requestData is dynamic
|
||||
// note: we are ignoring the error because requestInfo is dynamic
|
||||
// and some of the lookup keys may not be defined for the request
|
||||
|
||||
switch v := resultVal.(type) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestData := &models.RequestData{
|
||||
requestInfo := &models.RequestInfo{
|
||||
Headers: map[string]any{
|
||||
"a": "123",
|
||||
"b": "456",
|
||||
@@ -294,14 +294,14 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
"self_rel_one.rel_many_cascade.files:length != 7 &&" +
|
||||
"self_rel_one.rel_many_cascade.files:length ?!= 8",
|
||||
false,
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4` ON [[__data_demo4.id]]={:TEST} LEFT JOIN `demo3` `__data_demo3` ON [[__data_demo3.id]] IN ({:TEST}, {:TEST}) LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE json_array([[__data_demo4.self_rel_many]]) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE json_array([[__data_demo4.self_rel_many]]) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE json_array([[__data_demo3.files]]) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE json_array([[__data_demo3.files]]) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3.files]]) THEN [[__data_mm_demo3.files]] ELSE json_array([[__data_mm_demo3.files]]) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3` ON `__data_mm_demo3`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] < {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) != {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_cascade]]) END) `__mm_demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `__mm_demo4_self_rel_one_rel_many_cascade` ON [[__mm_demo4_self_rel_one_rel_many_cascade.id]] = [[__mm_demo4_self_rel_one_rel_many_cascade_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] != {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) != {:TEST})",
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `__data_demo4` ON [[__data_demo4.id]]={:TEST} LEFT JOIN `demo3` `__data_demo3` ON [[__data_demo3.id]] IN ({:TEST}, {:TEST}) LEFT JOIN `demo4` `demo4_self_rel_one` ON [[demo4_self_rel_one.id]] = [[demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[demo4_self_rel_one.rel_many_cascade]]) THEN [[demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[demo4_self_rel_one.rel_many_cascade]]) END) `demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `demo4_self_rel_one_rel_many_cascade` ON [[demo4_self_rel_one_rel_many_cascade.id]] = [[demo4_self_rel_one_rel_many_cascade_je.value]] WHERE (json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE (CASE WHEN [[__data_demo4.self_rel_many]] = '' OR [[__data_demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo4.self_rel_many]]) THEN [[__data_demo4.self_rel_many]] ELSE (CASE WHEN [[__data_demo4.self_rel_many]] = '' OR [[__data_demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[__data_demo4.self_rel_many]]) END) END) > {:TEST} AND json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE (CASE WHEN [[__data_demo3.files]] = '' OR [[__data_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3.files]]) END) END) < {:TEST} AND ((json_array_length(CASE WHEN json_valid([[__data_demo3.files]]) THEN [[__data_demo3.files]] ELSE (CASE WHEN [[__data_demo3.files]] = '' OR [[__data_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_demo3.files]]) END) END) < {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__data_mm_demo3.files]]) THEN [[__data_mm_demo3.files]] ELSE (CASE WHEN [[__data_mm_demo3.files]] = '' OR [[__data_mm_demo3.files]] IS NULL THEN json_array() ELSE json_array([[__data_mm_demo3.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo3` `__data_mm_demo3` ON `__data_mm_demo3`.`id` IN ({:TEST}, {:TEST}) WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] < {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one.self_rel_many]]) THEN [[demo4_self_rel_one.self_rel_many]] ELSE (CASE WHEN [[demo4_self_rel_one.self_rel_many]] = '' OR [[demo4_self_rel_one.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one.self_rel_many]]) END) END) = {:TEST} AND ((json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) != {:TEST}) AND (NOT EXISTS (SELECT 1 FROM (SELECT json_array_length(CASE WHEN json_valid([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) THEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[__mm_demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[__mm_demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[__mm_demo4_self_rel_one_rel_many_cascade.files]]) END) END) as [[multiMatchValue]] FROM `demo4` `__mm_demo4` LEFT JOIN `demo4` `__mm_demo4_self_rel_one` ON [[__mm_demo4_self_rel_one.id]] = [[__mm_demo4.self_rel_one]] LEFT JOIN json_each(CASE WHEN json_valid([[__mm_demo4_self_rel_one.rel_many_cascade]]) THEN [[__mm_demo4_self_rel_one.rel_many_cascade]] ELSE json_array([[__mm_demo4_self_rel_one.rel_many_cascade]]) END) `__mm_demo4_self_rel_one_rel_many_cascade_je` LEFT JOIN `demo3` `__mm_demo4_self_rel_one_rel_many_cascade` ON [[__mm_demo4_self_rel_one_rel_many_cascade.id]] = [[__mm_demo4_self_rel_one_rel_many_cascade_je.value]] WHERE `__mm_demo4`.`id` = `demo4`.`id`) {{__smTEST}} WHERE ((NOT ([[__smTEST.multiMatchValue]] != {:TEST})) OR ([[__smTEST.multiMatchValue]] IS NULL))))) AND json_array_length(CASE WHEN json_valid([[demo4_self_rel_one_rel_many_cascade.files]]) THEN [[demo4_self_rel_one_rel_many_cascade.files]] ELSE (CASE WHEN [[demo4_self_rel_one_rel_many_cascade.files]] = '' OR [[demo4_self_rel_one_rel_many_cascade.files]] IS NULL THEN json_array() ELSE json_array([[demo4_self_rel_one_rel_many_cascade.files]]) END) END) != {:TEST})",
|
||||
},
|
||||
{
|
||||
"json_extract and json_array_length COALESCE equal normalizations",
|
||||
"demo4",
|
||||
"json_object.a.b = '' && self_rel_many:length != 2 && json_object.a.b > 3 && self_rel_many:length <= 4",
|
||||
false,
|
||||
"SELECT `demo4`.* FROM `demo4` WHERE ((JSON_EXTRACT([[demo4.json_object]], '$.a.b') = '' OR JSON_EXTRACT([[demo4.json_object]], '$.a.b') IS NULL) AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) != {:TEST} AND JSON_EXTRACT([[demo4.json_object]], '$.a.b') > {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE json_array([[demo4.self_rel_many]]) END) <= {:TEST})",
|
||||
"SELECT `demo4`.* FROM `demo4` WHERE ((JSON_EXTRACT([[demo4.json_object]], '$.a.b') = '' OR JSON_EXTRACT([[demo4.json_object]], '$.a.b') IS NULL) AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE (CASE WHEN [[demo4.self_rel_many]] = '' OR [[demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4.self_rel_many]]) END) END) != {:TEST} AND JSON_EXTRACT([[demo4.json_object]], '$.a.b') > {:TEST} AND json_array_length(CASE WHEN json_valid([[demo4.self_rel_many]]) THEN [[demo4.self_rel_many]] ELSE (CASE WHEN [[demo4.self_rel_many]] = '' OR [[demo4.self_rel_many]] IS NULL THEN json_array() ELSE json_array([[demo4.self_rel_many]]) END) END) <= {:TEST})",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
|
||||
query := app.Dao().RecordQuery(collection)
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, s.allowHiddenFields)
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestInfo, s.allowHiddenFields)
|
||||
|
||||
expr, err := search.FilterData(s.rule).BuildExpr(r)
|
||||
if err != nil {
|
||||
@@ -353,11 +353,11 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestData := &models.RequestData{
|
||||
requestInfo := &models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
}
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestInfo, true)
|
||||
|
||||
scenarios := []struct {
|
||||
fieldName string
|
||||
@@ -424,7 +424,7 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
|
||||
func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -438,7 +438,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestData := &models.RequestData{
|
||||
requestInfo := &models.RequestInfo{
|
||||
Method: "get",
|
||||
Query: map[string]any{
|
||||
"a": 123,
|
||||
@@ -455,7 +455,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
|
||||
AuthRecord: authRecord,
|
||||
}
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestInfo, true)
|
||||
|
||||
scenarios := []struct {
|
||||
fieldName string
|
||||
|
||||
+25
-12
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ApiScenario defines a single api request test case/scenario.
|
||||
@@ -41,11 +42,22 @@ type ApiScenario struct {
|
||||
AfterTestFunc func(t *testing.T, app *TestApp, e *echo.Echo)
|
||||
}
|
||||
|
||||
// Test executes the test case/scenario.
|
||||
// Test executes the test scenario.
|
||||
func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
var name = scenario.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.Url)
|
||||
}
|
||||
|
||||
t.Run(name, scenario.test)
|
||||
}
|
||||
|
||||
func (scenario *ApiScenario) test(t *testing.T) {
|
||||
var testApp *TestApp
|
||||
var testAppErr error
|
||||
if scenario.TestAppFactory != nil {
|
||||
// @todo consider passing the testing instance to the factory and maybe remove the error from the declaration
|
||||
// (see https://github.com/pocketbase/pocketbase/discussions/3025)
|
||||
testApp, testAppErr = scenario.TestAppFactory()
|
||||
} else {
|
||||
testApp, testAppErr = NewTestApp()
|
||||
@@ -60,6 +72,12 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
||||
testApp.OnBeforeServe().Trigger(&core.ServeEvent{
|
||||
App: testApp,
|
||||
Router: e,
|
||||
})
|
||||
|
||||
if scenario.BeforeTestFunc != nil {
|
||||
scenario.BeforeTestFunc(t, testApp, e)
|
||||
}
|
||||
@@ -90,13 +108,8 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
|
||||
res := recorder.Result()
|
||||
|
||||
var prefix = scenario.Name
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("%s:%s", scenario.Method, scenario.Url)
|
||||
}
|
||||
|
||||
if res.StatusCode != scenario.ExpectedStatus {
|
||||
t.Errorf("[%s] Expected status code %d, got %d", prefix, scenario.ExpectedStatus, res.StatusCode)
|
||||
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
if scenario.Delay > 0 {
|
||||
@@ -105,7 +118,7 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
|
||||
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||
if len(recorder.Body.Bytes()) != 0 {
|
||||
t.Errorf("[%s] Expected empty body, got \n%v", prefix, recorder.Body.String())
|
||||
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
||||
}
|
||||
} else {
|
||||
// normalize json response format
|
||||
@@ -121,14 +134,14 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
|
||||
for _, item := range scenario.ExpectedContent {
|
||||
if !strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("[%s] Cannot find %v in response body \n%v", prefix, item, normalizedBody)
|
||||
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range scenario.NotExpectedContent {
|
||||
if strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("[%s] Didn't expect %v in response body \n%v", prefix, item, normalizedBody)
|
||||
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -149,13 +162,13 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(testApp.EventCalls) > len(scenario.ExpectedEvents) {
|
||||
t.Errorf("[%s] Expected events %v, got %v", prefix, scenario.ExpectedEvents, testApp.EventCalls)
|
||||
t.Errorf("Expected events %v, got %v", scenario.ExpectedEvents, testApp.EventCalls)
|
||||
}
|
||||
|
||||
for event, expectedCalls := range scenario.ExpectedEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Errorf("[%s] Expected event %s to be called %d, got %d", prefix, event, expectedCalls, actualCalls)
|
||||
t.Errorf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
app := core.NewBaseApp(&core.BaseAppConfig{
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: tempDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
types.d.ts
|
||||
|
||||
Binary file not shown.
+3
-3
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// NewAdminAuthToken generates and returns a new admin authentication token.
|
||||
func NewAdminAuthToken(app core.App, admin *models.Admin) (string, error) {
|
||||
return security.NewToken(
|
||||
return security.NewJWT(
|
||||
jwt.MapClaims{"id": admin.Id, "type": TypeAdmin},
|
||||
(admin.TokenKey + app.Settings().AdminAuthToken.Secret),
|
||||
app.Settings().AdminAuthToken.Duration,
|
||||
@@ -18,7 +18,7 @@ func NewAdminAuthToken(app core.App, admin *models.Admin) (string, error) {
|
||||
|
||||
// NewAdminResetPasswordToken generates and returns a new admin password reset request token.
|
||||
func NewAdminResetPasswordToken(app core.App, admin *models.Admin) (string, error) {
|
||||
return security.NewToken(
|
||||
return security.NewJWT(
|
||||
jwt.MapClaims{"id": admin.Id, "type": TypeAdmin, "email": admin.Email},
|
||||
(admin.TokenKey + app.Settings().AdminPasswordResetToken.Secret),
|
||||
app.Settings().AdminPasswordResetToken.Duration,
|
||||
@@ -27,7 +27,7 @@ func NewAdminResetPasswordToken(app core.App, admin *models.Admin) (string, erro
|
||||
|
||||
// NewAdminFileToken generates and returns a new admin private file access token.
|
||||
func NewAdminFileToken(app core.App, admin *models.Admin) (string, error) {
|
||||
return security.NewToken(
|
||||
return security.NewJWT(
|
||||
jwt.MapClaims{"id": admin.Id, "type": TypeAdmin},
|
||||
(admin.TokenKey + app.Settings().AdminFileToken.Secret),
|
||||
app.Settings().AdminFileToken.Duration,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user