mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-30 19:41:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9ec01d74d8 | ||
|
|
320f990f84 | ||
|
|
7297f55ca4 | ||
|
|
2cb642bbf7 | ||
|
|
ecdf9c26cd | ||
|
|
a672ab959f | ||
|
|
1571ebe4eb | ||
|
|
b8bb5e8d72 | ||
|
|
af77554250 | ||
|
|
3b68782cfb | ||
|
|
1679c88e6d | ||
|
|
91bbbc4bdb | ||
|
|
4ab9c6f87f | ||
|
|
68157a3a65 | ||
|
|
e6cf4ad2ef | ||
|
|
edd2eaae88 | ||
|
|
2e8e835a68 | ||
|
|
9cba6ac386 | ||
|
|
8388e36f28 | ||
|
|
051b3702b0 | ||
|
|
39accdba58 | ||
|
|
32de0aa40a | ||
|
|
9bfcdd086a | ||
|
|
1d20124467 | ||
|
|
435eca6f35 | ||
|
|
0a61db6efd | ||
|
|
7ab8405946 | ||
|
|
6fa3e99be2 | ||
|
|
3160fb2d99 | ||
|
|
1cbf16b3bf | ||
|
|
dad289b90d | ||
|
|
c795ecd21e | ||
|
|
21607f0f66 | ||
|
|
fc311a8d28 | ||
|
|
93606c6647 | ||
|
|
1adcfcc03b | ||
|
|
ed4304dc30 | ||
|
|
c0a6a21b9e | ||
|
|
a7bb599cd0 | ||
|
|
bd94940eef | ||
|
|
caf343ef9c | ||
|
|
28ba4655c1 | ||
|
|
bd95a5b74c | ||
|
|
745b230097 | ||
|
|
ec303a60ed | ||
|
|
e99b0627d6 | ||
|
|
d1bcaa65b1 | ||
|
|
2d60ad170e | ||
|
|
0ba963a5d7 | ||
|
|
b77b6d1a18 | ||
|
|
779b23d919 | ||
|
|
87a6f1bebb | ||
|
|
aa5b5bb83e | ||
|
|
af193d440d | ||
|
|
7f06816008 | ||
|
|
a5b27cce5c | ||
|
|
ebd6891471 | ||
|
|
3cf3e04866 | ||
|
|
ff5508cb79 | ||
|
|
e4a90f6605 | ||
|
|
f07f7a1e35 | ||
|
|
acbce42bff | ||
|
|
b33bebd4cd | ||
|
|
881b625177 | ||
|
|
4c2dcac61a | ||
|
|
dcb00a3917 | ||
|
|
ddca49ba16 | ||
|
|
0fb92720f8 | ||
|
|
3122223a71 | ||
|
|
7de346b532 | ||
|
|
729f9f142e | ||
|
|
45b73e3dfb | ||
|
|
dbbc1e25ca | ||
|
|
d3711b0503 | ||
|
|
9d8df8d05d | ||
|
|
97f29e4305 | ||
|
|
732044f795 | ||
|
|
9a1354ae62 | ||
|
|
fcfcaa0628 | ||
|
|
d5314b028b | ||
|
|
e3876c0e13 | ||
|
|
b1307c9041 | ||
|
|
dcdf43f0fc | ||
|
|
f6a616b7e8 | ||
|
|
3be5875ea9 | ||
|
|
833a84b3d7 | ||
|
|
94680c41f7 | ||
|
|
c72c951b24 | ||
|
|
af71b63f23 | ||
|
|
e40cf46b33 | ||
|
|
e9969ed6d1 | ||
|
|
511259ed10 | ||
|
|
6b16b7856b | ||
|
|
5b330ab5b4 | ||
|
|
7dcfa65146 | ||
|
|
a6bb1bf096 | ||
|
|
728427cecf | ||
|
|
231ddc9791 | ||
|
|
5bf2f692d8 | ||
|
|
ce28a9af78 | ||
|
|
db20e38cda | ||
|
|
651b439096 | ||
|
|
286046e15a | ||
|
|
7a41ff0127 | ||
|
|
4440b5f817 | ||
|
|
d7745ba702 | ||
|
|
007ef1e152 | ||
|
|
a291cb5ca7 | ||
|
|
86049ed048 | ||
|
|
6457fc794f | ||
|
|
0eecbd8289 | ||
|
|
6521decfa8 | ||
|
|
c370d84074 | ||
|
|
4f5aa6ffda | ||
|
|
8921712821 | ||
|
|
1c63ae1324 | ||
|
|
cbaca91581 | ||
|
|
5551f8f5aa | ||
|
|
a56a04ed0e | ||
|
|
47bb0bc2d8 | ||
|
|
f49e90bd0b | ||
|
|
3820f3d7d7 | ||
|
|
faaf19d3bb | ||
|
|
6d0303deaf | ||
|
|
d36a044dc7 | ||
|
|
1cc67ef7e3 | ||
|
|
1e2d1045b8 | ||
|
|
df5a291d20 | ||
|
|
04e0ad9b21 | ||
|
|
24ab233376 | ||
|
|
a42ab6a205 | ||
|
|
472671fee1 | ||
|
|
6bde84131c | ||
|
|
e8b4a7eb26 | ||
|
|
3b0f60fe15 | ||
|
|
d3314e1e23 | ||
|
|
60eee96034 | ||
|
|
c6d5992442 | ||
|
|
a7f3805f87 | ||
|
|
92f21e3355 | ||
|
|
4c57968d89 | ||
|
|
b84ebabf4a | ||
|
|
90abe1612e | ||
|
|
dfabfa779e | ||
|
|
28c9f02cb4 | ||
|
|
67e1974228 | ||
|
|
0dcd332962 | ||
|
|
f70b457507 | ||
|
|
c8fd831115 | ||
|
|
beca0a044e | ||
|
|
9fa56b020c | ||
|
|
429929dd0c | ||
|
|
ecf7d657c0 | ||
|
|
4296148c87 | ||
|
|
bfb38ab51e | ||
|
|
1967dcfeba | ||
|
|
5c95e9b109 | ||
|
|
39c3a95a08 | ||
|
|
74306c8092 | ||
|
|
a5b3cc0f34 | ||
|
|
4944884683 | ||
|
|
c0a7d0f6c0 | ||
|
|
841a4b6913 | ||
|
|
0478f84867 | ||
|
|
0b5e189563 | ||
|
|
7ce21cc4d9 | ||
|
|
e61d48fe7b | ||
|
|
7acd90498c | ||
|
|
cae3315e46 | ||
|
|
f2a011d63f | ||
|
|
b31c2ceffa | ||
|
|
d8f9b52011 | ||
|
|
faa0a9f7dc | ||
|
|
4d94673839 | ||
|
|
025c9313e1 | ||
|
|
0b023b2c02 | ||
|
|
3358d8476b | ||
|
|
060ed8013e | ||
|
|
d69e72deaf |
No files matched your search
@@ -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
|
||||
|
||||
+321
-14
@@ -1,3 +1,310 @@
|
||||
## 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 will be 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 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)._
|
||||
|
||||
|
||||
## v0.16.6
|
||||
|
||||
- Fixed collection index column sort normalization in the Admin UI ([#2681](https://github.com/pocketbase/pocketbase/pull/2681); thanks @SimonLoir).
|
||||
|
||||
- Removed unnecessary admins count in `apis.RequireAdminAuthOnlyIfAny()` middleware ([#2726](https://github.com/pocketbase/pocketbase/pull/2726); thanks @svekko).
|
||||
|
||||
- Fixed `multipart/form-data` request bind not populating map array values ([#2763](https://github.com/pocketbase/pocketbase/discussions/2763#discussioncomment-6278902)).
|
||||
|
||||
- Upgraded npm and Go dependencies.
|
||||
|
||||
|
||||
## v0.16.5
|
||||
|
||||
- Fixed the Admin UI serialization of implicit relation display fields ([#2675](https://github.com/pocketbase/pocketbase/issues/2675)).
|
||||
|
||||
- Reset the Admin UI sort in case the active sort collection field is renamed or deleted.
|
||||
|
||||
|
||||
## 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 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))
|
||||
|
||||
- Updated dependencies.
|
||||
|
||||
|
||||
## v0.16.3
|
||||
|
||||
- Fixed schema fields sort not working on Safari/Gnome Web ([#2567](https://github.com/pocketbase/pocketbase/issues/2567)).
|
||||
|
||||
- Fixed default `PRAGMA`s not being applied for new connections ([#2570](https://github.com/pocketbase/pocketbase/discussions/2570)).
|
||||
|
||||
|
||||
## v0.16.2
|
||||
|
||||
- Fixed backups archive not excluding the local `backups` directory on Windows ([#2548](https://github.com/pocketbase/pocketbase/discussions/2548#discussioncomment-5979712)).
|
||||
|
||||
- Changed file field to not use `dataTransfer.effectAllowed` when dropping files since it is not reliable and consistent across different OS and browsers ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
|
||||
|
||||
- Auto register the initial generated snapshot migration to prevent incorrectly reapplying the snapshot on Docker restart ([#2551](https://github.com/pocketbase/pocketbase/discussions/2551)).
|
||||
|
||||
- Fixed missing view id field error message typo.
|
||||
|
||||
|
||||
## v0.16.1
|
||||
|
||||
- Fixed backup restore not working in a container environment when `pb_data` is mounted as volume ([#2519](https://github.com/pocketbase/pocketbase/issues/2519)).
|
||||
|
||||
- Fixed Dart SDK realtime API preview example ([#2523](https://github.com/pocketbase/pocketbase/pull/2523); thanks @xFrann).
|
||||
|
||||
- Fixed typo in the backups create panel ([#2526](https://github.com/pocketbase/pocketbase/pull/2526); thanks @dschissler).
|
||||
|
||||
- Removed unnecessary slice length check in `list.ExistInSlice` ([#2527](https://github.com/pocketbase/pocketbase/pull/2527); thanks @KunalSin9h).
|
||||
|
||||
- Avoid mutating the cached request data on OAuth2 user create ([#2535](https://github.com/pocketbase/pocketbase/discussions/2535)).
|
||||
|
||||
- Fixed Export Collections "Download as JSON" ([#2540](https://github.com/pocketbase/pocketbase/issues/2540)).
|
||||
|
||||
- Fixed file field drag and drop not working in Firefox and Safari ([#2541](https://github.com/pocketbase/pocketbase/issues/2541)).
|
||||
|
||||
|
||||
## v0.16.0
|
||||
|
||||
- Added automated backups (_+ cron rotation_) APIs and UI for the `pb_data` directory.
|
||||
The backups can be also initialized programmatically using `app.CreateBackup("backup.zip")`.
|
||||
There is also experimental restore method - `app.RestoreBackup("backup.zip")` (_currently works only on UNIX systems as it relies on execve_).
|
||||
The backups can be stored locally or in external S3 storage (_it has its own configuration, separate from the file uploads storage filesystem_).
|
||||
|
||||
- Added option to limit the returned API fields using the `?fields` query parameter.
|
||||
The "fields picker" is applied for `SearchResult.Items` and every other JSON response. For example:
|
||||
```js
|
||||
// original: {"id": "RECORD_ID", "name": "abc", "description": "...something very big...", "items": ["id1", "id2"], "expand": {"items": [{"id": "id1", "name": "test1"}, {"id": "id2", "name": "test2"}]}}
|
||||
// output: {"name": "abc", "expand": {"items": [{"name": "test1"}, {"name": "test2"}]}}
|
||||
const result = await pb.collection("example").getOne("RECORD_ID", {
|
||||
expand: "items",
|
||||
fields: "name,expand.items.name",
|
||||
})
|
||||
```
|
||||
|
||||
- Added new `./pocketbase update` command to selfupdate the prebuilt executable (with option to generate a backup of your `pb_data`).
|
||||
|
||||
- Added new `./pocketbase admin` console command:
|
||||
```sh
|
||||
// creates new admin account
|
||||
./pocketbase admin create test@example.com 123456890
|
||||
|
||||
// changes the password of an existing admin account
|
||||
./pocketbase admin update test@example.com 0987654321
|
||||
|
||||
// deletes single admin account (if exists)
|
||||
./pocketbase admin delete test@example.com
|
||||
```
|
||||
|
||||
- Added `apis.Serve(app, options)` helper to allow starting the API server programmatically.
|
||||
|
||||
- Updated the schema fields Admin UI for "tidier" fields visualization.
|
||||
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
|
||||
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
|
||||
|
||||
- Added new `tools/cron` helper subpackage for scheduling task using cron-like syntax (_this eventually may get exported in the future in a separate repo_).
|
||||
|
||||
- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix.
|
||||
|
||||
- Added new `App.NewBackupsFilesystem()` helper to create a dedicated filesystem abstraction for managing app data backups.
|
||||
|
||||
- Added new `App.OnTerminate()` hook (_executed right before app termination, eg. on `SIGTERM` signal_).
|
||||
|
||||
- Added `accept` file field attribute with the field MIME types ([#2466](https://github.com/pocketbase/pocketbase/pull/2466); thanks @Nikhil1920).
|
||||
|
||||
- Added support for multiple files sort in the Admin UI ([#2445](https://github.com/pocketbase/pocketbase/issues/2445)).
|
||||
|
||||
- Added support for multiple relations sort in the Admin UI.
|
||||
|
||||
- Added `meta.isNew` to the OAuth2 auth JSON response to indicate a newly OAuth2 created PocketBase user.
|
||||
|
||||
|
||||
## v0.15.3
|
||||
|
||||
- Updated the Admin UI to use the latest JS SDK to resolve the `isNew` record field conflict ([#2385](https://github.com/pocketbase/pocketbase/discussions/2385)).
|
||||
|
||||
- Fixed `editor` field fullscreen `z-index` ([#2410](https://github.com/pocketbase/pocketbase/issues/2410)).
|
||||
|
||||
- Inserts the default app settings as part of the system init migration so that they are always available when accessed from within a user defined migration ([#2423](https://github.com/pocketbase/pocketbase/discussions/2423)).
|
||||
|
||||
|
||||
## v0.15.2
|
||||
|
||||
- Fixed View query `SELECT DISTINCT` identifiers parsing ([#2349-5706019](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5706019)).
|
||||
|
||||
- Fixed View collection schema incorrectly resolving multiple aliased fields originating from the same field source ([#2349-5707675](https://github.com/pocketbase/pocketbase/discussions/2349#discussioncomment-5707675)).
|
||||
|
||||
- Added OAuth2 redirect fallback message to notify the user to go back to the app in case the browser window is not auto closed.
|
||||
|
||||
|
||||
## v0.15.1
|
||||
|
||||
- Trigger the related `Record` model realtime subscription events on [custom model struct](https://pocketbase.io/docs/custom-models/) save ([#2325](https://github.com/pocketbase/pocketbase/discussions/2325)).
|
||||
@@ -17,15 +324,15 @@
|
||||
|
||||
- Simplified the OAuth2 authentication flow in a single "all in one" call ([#55](https://github.com/pocketbase/pocketbase/issues/55)).
|
||||
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
|
||||
The manual code-token exchange flow is still supported but the SDK methods is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
|
||||
The manual code-token exchange flow is still supported but the SDK method is renamed to `authWithOAuth2Code()` (_to minimize the breaking changes the JS SDK has a function overload that will proxy the existing `authWithOauth2` calls to `authWithOAuth2Code`_).
|
||||
For more details and example, you could check https://pocketbase.io/docs/authentication/#oauth2-integration.
|
||||
|
||||
- Added support for protected files ([#215](https://github.com/pocketbase/pocketbase/issues/215)).
|
||||
Requires JS SDK v0.14.0+ or Dart SDK v0.9.0+.
|
||||
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/#private-files.
|
||||
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.
|
||||
|
||||
@@ -116,16 +423,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.
|
||||
|
||||
@@ -182,11 +489,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
|
||||
@@ -206,11 +513,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.
|
||||
|
||||
@@ -315,7 +622,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// bindBackupApi registers the file api endpoints and the corresponding handlers.
|
||||
//
|
||||
// @todo add hooks once the app hooks api restructuring is finalized
|
||||
func bindBackupApi(app core.App, rg *echo.Group) {
|
||||
api := backupApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/backups", ActivityLogger(app))
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuth())
|
||||
subGroup.GET("/:key", api.download)
|
||||
subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
|
||||
subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
|
||||
}
|
||||
|
||||
type backupApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *backupApi) list(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
backups, err := fsys.List("")
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
result := make([]models.BackupFileInfo, len(backups))
|
||||
|
||||
for i, obj := range backups {
|
||||
modified, _ := types.ParseDateTime(obj.ModTime)
|
||||
|
||||
result[i] = models.BackupFileInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
Modified: modified,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *backupApi) create(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
|
||||
}
|
||||
|
||||
form := forms.NewBackupCreate(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
|
||||
return func(name string) error {
|
||||
if err := next(name); err != nil {
|
||||
return NewBadRequestError("Failed to create backup.", err)
|
||||
}
|
||||
|
||||
// we don't retrieve the generated backup file because it may not be
|
||||
// available yet due to the eventually consistent nature of some S3 providers
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (api *backupApi) download(c echo.Context) error {
|
||||
fileToken := c.QueryParam("token")
|
||||
|
||||
_, err := api.app.Dao().FindAdminByToken(
|
||||
fileToken,
|
||||
api.app.Settings().AdminFileToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return NewForbiddenError("Insufficient permissions to access the resource.", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
br, err := fsys.GetFile(key)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
return fsys.Serve(
|
||||
c.Response(),
|
||||
c.Request(),
|
||||
key,
|
||||
filepath.Base(key), // without the path prefix (if any)
|
||||
)
|
||||
}
|
||||
|
||||
func (api *backupApi) restore(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
|
||||
}
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(existsCtx)
|
||||
|
||||
if exists, err := fsys.Exists(key); !exists {
|
||||
return NewBadRequestError("Missing or invalid backup file.", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
// wait max 15 minutes to fetch the backup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// give some optimistic time to write the response
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *backupApi) delete(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
|
||||
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
|
||||
}
|
||||
|
||||
if err := fsys.Delete(key); err != nil {
|
||||
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"gocloud.dev/blob"
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (empty list)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
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)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"test1.zip"`,
|
||||
`"test2.zip"`,
|
||||
`"test3.zip"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsCreate(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (pending backup)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (autogenerated name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
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 != 1 {
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
}
|
||||
|
||||
expected := "pb_backup_"
|
||||
if !strings.HasPrefix(files[0].Key, expected) {
|
||||
t.Fatalf("Expected backup file with prefix %q, got %q", expected, files[0].Key)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"!test.zip"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"name":{"code":"validation_match_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"test.zip"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
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 != 1 {
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
}
|
||||
|
||||
expected := "test.zip"
|
||||
if files[0].Key != expected {
|
||||
t.Fatalf("Expected backup file %q, got %q", expected, files[0].Key)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsDownload(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with record auth header",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with admin auth header",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.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)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with empty or invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid record auth token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid record file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin auth token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with expired admin file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token but missing backup name",
|
||||
Method: http.MethodGet,
|
||||
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)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.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`,
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsDelete(t *testing.T) {
|
||||
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := 4
|
||||
if total := len(files); total != expected {
|
||||
t.Fatalf("Expected %d backup(s), got %d", expected, total)
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
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) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
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) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/missing.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) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing file with matching active backup)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing file and no matching active backup)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.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)
|
||||
}
|
||||
|
||||
// mock active backup with different name
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
|
||||
},
|
||||
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 := "test1.zip"
|
||||
|
||||
for _, f := range files {
|
||||
if f.Key == deletedFile {
|
||||
t.Fatalf("Expected backup %q to be deleted", deletedFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
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 {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsRestore(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/missing.zip/restore",
|
||||
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)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (active backup process)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
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)
|
||||
}
|
||||
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func createTestBackups(app core.App) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if err := app.CreateBackup(ctx, "test1.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "test2.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "test3.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
return fsys.List("")
|
||||
}
|
||||
|
||||
func ensureNoBackups(t *testing.T, app *tests.TestApp) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 0 {
|
||||
t.Fatalf("Expected 0 backup files, got %d", total)
|
||||
}
|
||||
}
|
||||
+49
-43
@@ -2,6 +2,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -25,11 +27,15 @@ const trailedAdminPath = "/_/"
|
||||
func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
e.JSONSerializer = &rest.Serializer{
|
||||
FieldsParam: "fields",
|
||||
}
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
AllowOverwritingRoute: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,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)
|
||||
@@ -55,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)
|
||||
@@ -80,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)
|
||||
@@ -88,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)
|
||||
@@ -110,20 +129,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(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))
|
||||
bindBackupApi(app, api)
|
||||
|
||||
// catch all any route
|
||||
api.Any("/*", func(c echo.Context) error {
|
||||
@@ -187,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 {
|
||||
@@ -214,6 +207,19 @@ func uiCacheControl() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
+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
|
||||
}
|
||||
+119
-1
@@ -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"
|
||||
@@ -299,7 +301,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 +309,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 +558,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 +764,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,
|
||||
@@ -1373,6 +1439,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
|
||||
}
|
||||
+13
-5
@@ -19,12 +19,20 @@ type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
type healthCheckResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
CanBackup bool `json:"canBackup"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
}
|
||||
resp := new(healthCheckResponse)
|
||||
resp.Code = http.StatusOK
|
||||
resp.Message = "API is healthy."
|
||||
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ func TestHealthAPI(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
`"data":{`,
|
||||
`"canBackup":true`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+16
-9
@@ -129,14 +129,17 @@ func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to fetch admins info.", err)
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
if admin != nil || totalAdmins == 0 {
|
||||
if totalAdmins == 0 {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -368,15 +371,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
|
||||
// extract the first non-empty leftmost-ish ip
|
||||
ips := strings.Split(ipsList, ",")
|
||||
// extract the rightmost ip
|
||||
for i := len(ips) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(ips[i])
|
||||
for _, ip := range ips {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
@@ -386,15 +393,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) {
|
||||
|
||||
+94
-92
@@ -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{
|
||||
@@ -190,9 +189,10 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
event.IsNewRecord = true
|
||||
// clone the current request data and assign the form create data as its body data
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -205,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
|
||||
@@ -242,17 +242,21 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
e.Record = data.Record
|
||||
e.OAuth2User = data.OAuth2User
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
|
||||
meta := struct {
|
||||
*auth.AuthUser
|
||||
IsNew bool `json:"isNew"`
|
||||
}{
|
||||
AuthUser: e.OAuth2User,
|
||||
IsNew: event.IsNewRecord,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -282,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
|
||||
}
|
||||
|
||||
@@ -332,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 {
|
||||
@@ -377,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
|
||||
}
|
||||
|
||||
@@ -422,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 {
|
||||
@@ -467,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
|
||||
}
|
||||
|
||||
@@ -502,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 {
|
||||
@@ -545,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
|
||||
}
|
||||
|
||||
@@ -619,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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -642,9 +640,13 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
state := c.QueryParam("state")
|
||||
code := c.QueryParam("code")
|
||||
|
||||
if code == "" || state == "" {
|
||||
return NewBadRequestError("Invalid OAuth2 redirect parameters.", nil)
|
||||
}
|
||||
|
||||
client, err := api.app.SubscriptionsBroker().ClientById(state)
|
||||
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
|
||||
return NewNotFoundError("Missing or invalid oauth2 subscription client", err)
|
||||
return NewNotFoundError("Missing or invalid OAuth2 subscription client.", err)
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
@@ -654,15 +656,15 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to marshalize oauth2 redirect data", err)
|
||||
return NewBadRequestError("Failed to marshalize OAuth2 redirect data.", err)
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: oauth2SubscriptionTopic,
|
||||
Data: string(encodedData),
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
client.Send(msg)
|
||||
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/auth/oauth2-redirect")
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
|
||||
}
|
||||
+148
-15
@@ -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 {
|
||||
@@ -1175,21 +1301,28 @@ func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
{
|
||||
Name: "no state query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect",
|
||||
ExpectedStatus: 404,
|
||||
Url: "/api/oauth2-redirect?code=123",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "no code query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + c3.Id(),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "missing client",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=missing",
|
||||
Url: "/api/oauth2-redirect?code=123&state=missing",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "discarded client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + c5.Id(),
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c5.Id(),
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -1197,7 +1330,7 @@ func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
{
|
||||
Name: "client without @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + c4.Id(),
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c4.Id(),
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -1205,7 +1338,7 @@ func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
{
|
||||
Name: "client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + c3.Id(),
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + c3.Id(),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
beforeTestFunc(t, app, e)
|
||||
|
||||
@@ -1229,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
-60
@@ -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,15 +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))
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
@@ -86,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)
|
||||
}
|
||||
@@ -105,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
|
||||
@@ -136,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)
|
||||
}
|
||||
@@ -150,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)
|
||||
@@ -180,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
|
||||
@@ -195,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
|
||||
})
|
||||
|
||||
@@ -220,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
|
||||
|
||||
@@ -233,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 {
|
||||
@@ -258,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
|
||||
@@ -295,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 {
|
||||
@@ -309,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
|
||||
|
||||
@@ -322,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 {
|
||||
@@ -347,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
|
||||
@@ -377,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"
|
||||
)
|
||||
@@ -836,6 +838,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 +1210,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 +1787,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,
|
||||
|
||||
+57
-28
@@ -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,19 +141,21 @@ 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)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
|
||||
if param := c.QueryParam(expandQueryParam); param != "" {
|
||||
expands = append(expands, strings.Split(param, ",")...)
|
||||
}
|
||||
if len(expands) == 0 {
|
||||
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)
|
||||
}
|
||||
@@ -137,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
|
||||
}
|
||||
|
||||
@@ -150,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
|
||||
@@ -163,7 +192,7 @@ func expandFetch(
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
|
||||
}
|
||||
|
||||
return records, err
|
||||
@@ -177,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)
|
||||
}
|
||||
@@ -199,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()
|
||||
@@ -216,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
|
||||
@@ -245,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
|
||||
@@ -257,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 {
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// 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 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, 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 nil, err
|
||||
}
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
|
||||
router, err := InitApi(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := config.HttpAddr
|
||||
if config.HttpsAddr != "" {
|
||||
mainAddr = config.HttpsAddr
|
||||
}
|
||||
|
||||
mainHost, _, _ := net.SplitHostPort(mainAddr)
|
||||
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 10 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
}
|
||||
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: router,
|
||||
Server: server,
|
||||
CertManager: certManager,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.ShowStartBanner {
|
||||
schema := "http"
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, server.Addr),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
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()
|
||||
server.Shutdown(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
// start HTTPS server
|
||||
if config.HttpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if config.HttpAddr != "" {
|
||||
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
|
||||
}
|
||||
|
||||
return server, server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// OR start HTTP server
|
||||
return server, server.ListenAndServe()
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := runner.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+29
-35
@@ -1,8 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -10,7 +8,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindSettingsApi registers the settings api endpoints.
|
||||
@@ -40,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)
|
||||
})
|
||||
}
|
||||
@@ -57,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
|
||||
|
||||
@@ -66,47 +67,40 @@ 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 {
|
||||
if !api.app.Settings().S3.Enabled {
|
||||
return NewBadRequestError("S3 storage is not enabled.", nil)
|
||||
form := forms.NewTestS3Filesystem(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer fs.Close()
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
|
||||
}
|
||||
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
// mailer error
|
||||
return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
||||
+69
-3
@@ -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"
|
||||
)
|
||||
|
||||
@@ -47,6 +49,7 @@ func TestSettingsList(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
@@ -74,6 +77,9 @@ func TestSettingsList(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -125,6 +131,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
@@ -151,6 +158,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -190,6 +200,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
@@ -217,6 +228,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -228,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 {
|
||||
@@ -255,14 +291,44 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (no s3)",
|
||||
Name: "authorized as admin (missing body + no s3)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"filesystem":{`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid filesystem)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"invalid"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"filesystem":{`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid filesystem and no s3)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"storage"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewAdminCommand creates and returns new command for managing
|
||||
// admin accounts (create, update, delete).
|
||||
func NewAdminCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "admin",
|
||||
Short: "Manages admin accounts",
|
||||
}
|
||||
|
||||
command.AddCommand(adminCreateCommand(app))
|
||||
command.AddCommand(adminUpdateCommand(app))
|
||||
command.AddCommand(adminDeleteCommand(app))
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminCreateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "create",
|
||||
Example: "admin create test@example.com 1234567890",
|
||||
Short: "Creates a new admin account",
|
||||
// prevents printing the error log twice
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
}
|
||||
|
||||
if len(args[1]) < 8 {
|
||||
return errors.New("The password must be at least 8 chars long.")
|
||||
}
|
||||
|
||||
admin := &models.Admin{}
|
||||
admin.Email = args[0]
|
||||
admin.SetPassword(args[1])
|
||||
|
||||
if err := app.Dao().SaveAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to create new admin account: %v", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully created new admin %s!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminUpdateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "update",
|
||||
Example: "admin update test@example.com 1234567890",
|
||||
Short: "Changes the password of a single admin account",
|
||||
// prevents printing the error log twice
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
}
|
||||
|
||||
if len(args[1]) < 8 {
|
||||
return errors.New("The new password must be at least 8 chars long.")
|
||||
}
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
|
||||
}
|
||||
|
||||
admin.SetPassword(args[1])
|
||||
|
||||
if err := app.Dao().SaveAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to change admin %s password: %v", admin.Email, err)
|
||||
}
|
||||
|
||||
color.Green("Successfully changed admin %s password!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminDeleteCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "delete",
|
||||
Example: "admin delete test@example.com",
|
||||
Short: "Deletes an existing admin account",
|
||||
// prevents printing the error log twice
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
}
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail(args[0])
|
||||
if err != nil {
|
||||
color.Yellow("Admin %s is already deleted.", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to delete admin %s: %v", admin.Email, err)
|
||||
}
|
||||
|
||||
color.Green("Successfully deleted admin %s!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/cmd"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminCreateCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email and password",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"duplicated email",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"test@example.com",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"short password",
|
||||
"test_new@example.com",
|
||||
"1234567",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid email and password",
|
||||
"test_new@example.com",
|
||||
"12345678",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"create", s.email, s.password})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin account was actually created
|
||||
admin, err := app.Dao().FindAdminByEmail(s.email)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch created admin %s: %v", s.name, s.email, err)
|
||||
} else if !admin.ValidatePassword(s.password) {
|
||||
t.Errorf("[%s] Expected the admin password to match", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email and password",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting admin",
|
||||
"test_missing@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"test@example.com",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"short password",
|
||||
"test_new@example.com",
|
||||
"1234567",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid email and password",
|
||||
"test@example.com",
|
||||
"12345678",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"update", s.email, s.password})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin password was actually changed
|
||||
admin, err := app.Dao().FindAdminByEmail(s.email)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch admin %s: %v", s.name, s.email, err)
|
||||
} else if !admin.ValidatePassword(s.password) {
|
||||
t.Errorf("[%s] Expected the admin password to match", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeleteCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting admin",
|
||||
"test_missing@example.com",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing admin",
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"delete", s.email})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin account was actually deleted
|
||||
if _, err := app.Dao().FindAdminByEmail(s.email); err == nil {
|
||||
t.Errorf("[%s] Expected the admin account to be deleted", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-133
@@ -1,25 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// NewServeCommand creates and returns new command responsible for
|
||||
@@ -33,96 +20,15 @@ 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) {
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
})
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
|
||||
router, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := httpAddr
|
||||
if httpsAddr != "" {
|
||||
mainAddr = httpsAddr
|
||||
}
|
||||
|
||||
mainHost, _, _ := net.SplitHostPort(mainAddr)
|
||||
|
||||
certManager := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
|
||||
}
|
||||
|
||||
serverConfig := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
}
|
||||
|
||||
if showStartBanner {
|
||||
schema := "http"
|
||||
if httpsAddr != "" {
|
||||
schema = "https"
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, serverConfig.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))
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
if httpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if httpAddr != "" {
|
||||
go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil))
|
||||
}
|
||||
|
||||
// start HTTPS server
|
||||
serveErr = serverConfig.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
// start HTTP server
|
||||
serveErr = serverConfig.ListenAndServe()
|
||||
}
|
||||
|
||||
if serveErr != http.ErrServerClosed {
|
||||
log.Fatalln(serveErr)
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -150,34 +56,3 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := runner.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"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/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9+
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
|
||||
Long: `
|
||||
Upgrades your existing pb_data to be compatible with the v0.8.x changes
|
||||
Prerequisites and caveats:
|
||||
- already upgraded to v0.7.*
|
||||
- no existing users collection
|
||||
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
|
||||
`,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
if err := upgrade(app); err != nil {
|
||||
color.Red("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func upgrade(app core.App) error {
|
||||
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
|
||||
}
|
||||
|
||||
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := migrateCollections(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUsers(app, txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resetMigrationsTable(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Println("The pb_data upgrade completed successfully!")
|
||||
bold.Println("You can now start the application as usual with the 'serve' command.")
|
||||
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func migrateCollections(txDao *daos.Dao) error {
|
||||
// add new collection columns
|
||||
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleReplacements := []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"expand", "expand2"},
|
||||
{"collecitonId", "collectionId2"},
|
||||
{"collecitonName", "collectionName2"},
|
||||
{"profile.userId", "profile.id"},
|
||||
|
||||
// @collection.*
|
||||
{"@collection.profiles.userId", "@collection.users.id"},
|
||||
{"@collection.profiles.username", "@collection.users.username2"},
|
||||
{"@collection.profiles.email", "@collection.users.email2"},
|
||||
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
|
||||
{"@collection.profiles.verified", "@collection.users.verified2"},
|
||||
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
|
||||
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
|
||||
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
|
||||
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
|
||||
{"@collection.profiles.", "@collection.users."},
|
||||
|
||||
// @request.*
|
||||
{"@request.user.profile.userId", "@request.auth.id"},
|
||||
{"@request.user.profile.username", "@request.auth.username2"},
|
||||
{"@request.user.profile.email", "@request.auth.email2"},
|
||||
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
|
||||
{"@request.user.profile.verified", "@request.auth.verified2"},
|
||||
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
|
||||
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
|
||||
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
|
||||
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
|
||||
{"@request.user.profile.", "@request.auth."},
|
||||
{"@request.user", "@request.auth"},
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.NormalizeOptions()
|
||||
|
||||
// rename profile fields
|
||||
// ---
|
||||
fieldsToRename := []string{
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"expand",
|
||||
}
|
||||
if collection.Name == "profiles" {
|
||||
fieldsToRename = append(fieldsToRename,
|
||||
"username",
|
||||
"email",
|
||||
"emailVisibility",
|
||||
"verified",
|
||||
"tokenKey",
|
||||
"passwordHash",
|
||||
"lastResetSentAt",
|
||||
"lastVerificationSentAt",
|
||||
)
|
||||
}
|
||||
for _, name := range fieldsToRename {
|
||||
f := collection.Schema.GetFieldByName(name)
|
||||
if f != nil {
|
||||
color.Blue("[%s - renamed field]", collection.Name)
|
||||
color.Yellow(" - old: %s", f.Name)
|
||||
color.Green(" - new: %s2", f.Name)
|
||||
fmt.Println()
|
||||
f.Name += "2"
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
// replace rule fields
|
||||
// ---
|
||||
rules := map[string]*string{
|
||||
"ListRule": collection.ListRule,
|
||||
"ViewRule": collection.ViewRule,
|
||||
"CreateRule": collection.CreateRule,
|
||||
"UpdateRule": collection.UpdateRule,
|
||||
"DeleteRule": collection.DeleteRule,
|
||||
}
|
||||
|
||||
for ruleKey, rule := range rules {
|
||||
if rule == nil || *rule == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
originalRule := *rule
|
||||
|
||||
for _, replacement := range ruleReplacements {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
|
||||
*rule = re.ReplaceAllString(*rule, replacement.new)
|
||||
}
|
||||
|
||||
*rule = replaceReversedLikes(*rule)
|
||||
|
||||
if originalRule != *rule {
|
||||
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
|
||||
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
|
||||
color.Green(" - new: %s", strings.TrimSpace(*rule))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
if err := txDao.SaveCollection(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(app core.App, txDao *daos.Dao) error {
|
||||
color.Blue(`[merging "_users" and "profiles"]:`)
|
||||
|
||||
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalProfilesCollectionId := profilesCollection.Id
|
||||
|
||||
// change the profiles collection id to something else since we will be using
|
||||
// it for the new users collection in order to avoid renaming the storage dir
|
||||
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{_collections}}
|
||||
SET id = '%s'
|
||||
WHERE id = '%s';
|
||||
`,
|
||||
(originalProfilesCollectionId + "__old__"),
|
||||
originalProfilesCollectionId,
|
||||
)).Execute()
|
||||
if idRenameErr != nil {
|
||||
return idRenameErr
|
||||
}
|
||||
|
||||
// refresh profiles collection
|
||||
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersSchema, _ := profilesCollection.Schema.Clone()
|
||||
userIdField := usersSchema.GetFieldByName("userId")
|
||||
if userIdField != nil {
|
||||
usersSchema.RemoveField(userIdField.Id)
|
||||
}
|
||||
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
usersCollection.Id = originalProfilesCollectionId
|
||||
usersCollection.Name = "users"
|
||||
usersCollection.Type = models.CollectionTypeAuth
|
||||
usersCollection.Schema = *usersSchema
|
||||
usersCollection.CreateRule = types.Pointer("")
|
||||
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
|
||||
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
|
||||
usersCollection.ListRule = profilesCollection.ListRule
|
||||
}
|
||||
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
|
||||
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
|
||||
usersCollection.ViewRule = profilesCollection.ViewRule
|
||||
}
|
||||
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
|
||||
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
|
||||
usersCollection.UpdateRule = profilesCollection.UpdateRule
|
||||
}
|
||||
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
|
||||
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
|
||||
usersCollection.DeleteRule = profilesCollection.DeleteRule
|
||||
}
|
||||
|
||||
// set auth options
|
||||
settings := app.Settings()
|
||||
authOptions := usersCollection.AuthOptions()
|
||||
authOptions.ManageRule = nil
|
||||
authOptions.AllowOAuth2Auth = true
|
||||
authOptions.AllowUsernameAuth = false
|
||||
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
|
||||
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
|
||||
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
|
||||
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
|
||||
// twitter currently is the only provider that doesn't return an email
|
||||
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
|
||||
|
||||
usersCollection.SetOptions(authOptions)
|
||||
|
||||
if err := txDao.SaveCollection(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy the original users
|
||||
_, usersErr := txDao.DB().NewQuery(`
|
||||
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
|
||||
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
|
||||
FROM {{_users}};
|
||||
`).Execute()
|
||||
if usersErr != nil {
|
||||
return usersErr
|
||||
}
|
||||
|
||||
// generate the profile fields copy statements
|
||||
sets := []string{"id = p.id"}
|
||||
for _, f := range usersSchema.Fields() {
|
||||
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
|
||||
}
|
||||
|
||||
// copy profile fields
|
||||
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE {{users}} as u
|
||||
SET %s
|
||||
FROM {{profiles}} as p
|
||||
WHERE u.id = p.userId;
|
||||
`, strings.Join(sets, ", "))).Execute()
|
||||
if copyProfileErr != nil {
|
||||
return copyProfileErr
|
||||
}
|
||||
|
||||
profileRecords, err := txDao.FindRecordsByExpr("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update all profiles and users fields to point to the new users collection
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, collection := range collections {
|
||||
var hasChanges bool
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
f.InitOptions()
|
||||
|
||||
if f.Type == schema.FieldTypeUser {
|
||||
if collection.Name == "profiles" && f.Name == "userId" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasChanges = true
|
||||
|
||||
// change the user field to a relation field
|
||||
options, _ := f.Options.(*schema.UserOptions)
|
||||
f.Type = schema.FieldTypeRelation
|
||||
f.Options = &schema.RelationOptions{
|
||||
CollectionId: usersCollection.Id,
|
||||
MaxSelect: &options.MaxSelect,
|
||||
CascadeDelete: options.CascadeDelete,
|
||||
}
|
||||
|
||||
for _, p := range profileRecords {
|
||||
pId := p.Id
|
||||
pUserId := p.GetString("userId")
|
||||
// replace all user record id references with the profile id
|
||||
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
|
||||
WHERE [[%s]] LIKE ('%%%s%%');
|
||||
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop _users table
|
||||
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop profiles table
|
||||
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete profiles collection
|
||||
if err := txDao.Delete(profilesCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
|
||||
_, alterErr := txDao.DB().NewQuery(`
|
||||
-- crate new externalAuths table
|
||||
CREATE TABLE {{_newExternalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- copy all data from the old table to the new one
|
||||
INSERT INTO {{_newExternalAuths}}
|
||||
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
|
||||
FROM {{_externalAuths}} auth
|
||||
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
|
||||
|
||||
-- drop old table
|
||||
DROP TABLE {{_externalAuths}};
|
||||
|
||||
-- rename new table
|
||||
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
|
||||
|
||||
-- create named indexes
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
|
||||
return alterErr
|
||||
}
|
||||
|
||||
func resetMigrationsTable(txDao *daos.Dao) error {
|
||||
// reset the migration state to the new init
|
||||
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
|
||||
"file": "1661586591_add_externalAuths_table.go",
|
||||
}).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
|
||||
|
||||
func replaceReversedLikes(rule string) string {
|
||||
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if len(p) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
|
||||
|
||||
rule = strings.ReplaceAll(rule, p[0], newPart)
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
+115
-85
@@ -4,6 +4,8 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
@@ -69,12 +71,20 @@ type App interface {
|
||||
// NewMailClient creates and returns a configured app mail client.
|
||||
NewMailClient() mailer.Mailer
|
||||
|
||||
// NewFilesystem creates and returns a configured filesystem.System instance.
|
||||
// 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
|
||||
// after you are done working with it.
|
||||
NewBackupsFilesystem() (*filesystem.System, error)
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
@@ -92,25 +102,50 @@ type App interface {
|
||||
// (eg. closing db connections).
|
||||
ResetBootstrapState() error
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
//
|
||||
// Please refer to the godoc of the specific core.App implementation
|
||||
// for details on the backup procedures.
|
||||
CreateBackup(ctx context.Context, name string) error
|
||||
|
||||
// RestoreBackup restores the backup with the specified name and restarts
|
||||
// the current running application process.
|
||||
//
|
||||
// The safely perform the restore it is recommended to have free disk space
|
||||
// for at least 2x the size of the restored pb_data backup.
|
||||
//
|
||||
// Please refer to the godoc of the specific core.App implementation
|
||||
// for details on the restore procedures.
|
||||
//
|
||||
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
|
||||
RestoreBackup(ctx context.Context, name string) error
|
||||
|
||||
// Restart restarts the current running application process.
|
||||
//
|
||||
// Currently it is relying on execve so it is supported only on UNIX based systems.
|
||||
Restart() error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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
|
||||
@@ -118,12 +153,16 @@ type App interface {
|
||||
// It could be used to log the final API error in external services.
|
||||
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnTerminate hook is triggered when the app is in the process
|
||||
// of being terminated (eg. on SIGTERM signal).
|
||||
OnTerminate() *hook.Hook[*TerminateEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// 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
|
||||
@@ -131,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
|
||||
@@ -139,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
|
||||
@@ -147,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
|
||||
@@ -155,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
|
||||
@@ -174,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
|
||||
@@ -204,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
|
||||
@@ -224,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
|
||||
@@ -289,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
|
||||
@@ -344,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
|
||||
@@ -355,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
|
||||
@@ -366,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
|
||||
@@ -395,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
|
||||
@@ -406,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
|
||||
@@ -417,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
|
||||
@@ -443,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
|
||||
@@ -461,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
|
||||
@@ -484,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
|
||||
@@ -499,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
|
||||
@@ -522,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
|
||||
@@ -541,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
|
||||
@@ -560,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
|
||||
@@ -579,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
|
||||
@@ -598,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
|
||||
@@ -613,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
|
||||
// ---------------------------------------------------------------
|
||||
@@ -667,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
|
||||
@@ -686,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
|
||||
@@ -705,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
|
||||
@@ -738,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
|
||||
@@ -749,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
|
||||
@@ -760,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
|
||||
@@ -771,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
|
||||
|
||||
+93
-25
@@ -7,6 +7,8 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -27,6 +29,10 @@ const (
|
||||
DefaultDataMaxIdleConns int = 20
|
||||
DefaultLogsMaxOpenConns int = 10
|
||||
DefaultLogsMaxIdleConns int = 2
|
||||
|
||||
LocalStorageDirName string = "storage"
|
||||
LocalBackupsDirName string = "backups"
|
||||
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
||||
)
|
||||
|
||||
var _ App = (*BaseApp)(nil)
|
||||
@@ -55,6 +61,7 @@ type BaseApp struct {
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
onBeforeApiError *hook.Hook[*ApiErrorEvent]
|
||||
onAfterApiError *hook.Hook[*ApiErrorEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate *hook.Hook[*ModelEvent]
|
||||
@@ -173,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,
|
||||
@@ -192,6 +199,7 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onTerminate: &hook.Hook[*TerminateEvent]{},
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
|
||||
@@ -338,11 +346,10 @@ func (app *BaseApp) Bootstrap() error {
|
||||
// we don't check for an error because the db migrations may have not been executed yet
|
||||
app.RefreshSettings()
|
||||
|
||||
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
// cleanup the pb_data temp directory (if any)
|
||||
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
|
||||
|
||||
return nil
|
||||
return app.OnAfterBootstrap().Trigger(event)
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -471,9 +478,10 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
}
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// 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 {
|
||||
@@ -488,7 +496,54 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
}
|
||||
|
||||
// fallback to local filesystem
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage"))
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
}
|
||||
|
||||
// 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
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.Backups.S3.Enabled {
|
||||
return filesystem.NewS3(
|
||||
app.settings.Backups.S3.Bucket,
|
||||
app.settings.Backups.S3.Region,
|
||||
app.settings.Backups.S3.Endpoint,
|
||||
app.settings.Backups.S3.AccessKey,
|
||||
app.settings.Backups.S3.Secret,
|
||||
app.settings.Backups.S3.ForcePathStyle,
|
||||
)
|
||||
}
|
||||
|
||||
// fallback to local filesystem
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
|
||||
}
|
||||
|
||||
// Restart restarts (aka. replaces) the current running application process.
|
||||
//
|
||||
// NB! It relies on execve which is supported only on UNIX based systems.
|
||||
func (app *BaseApp) Restart() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("restart is not supported on windows")
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// optimistically reset the app bootstrap state
|
||||
app.ResetBootstrapState()
|
||||
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
// restart the app bootstrap state
|
||||
app.Bootstrap()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
@@ -541,6 +596,10 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onAfterApiError
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
|
||||
return app.onTerminate
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -990,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
|
||||
@@ -1084,4 +1143,13 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
app.ResetBootstrapState()
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
const CacheKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
// If backup with the same name exists, the new backup file will replace it.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// When using S3 storage for the uploaded collection files, you have to
|
||||
// take care manually to backup those since they are not part of the pb_data.
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
// auto generate backup name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf(
|
||||
"pb_backup_%s.zip",
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
|
||||
// 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))
|
||||
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := archive.Create(app.DataDir(), tempPath, LocalBackupsDirName); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
// Persist the backup in the backups filesystem.
|
||||
// ---
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
file, err := filesystem.NewFileFromPath(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.OriginalName = name
|
||||
file.Name = file.OriginalName
|
||||
|
||||
if err := fsys.UploadFile(file, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores the backup with the specified name and restarts
|
||||
// the current running application process.
|
||||
//
|
||||
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
|
||||
//
|
||||
// To safely perform the restore it is recommended to have free disk space
|
||||
// for at least 2x the size of the restored pb_data backup.
|
||||
//
|
||||
// The performed steps are:
|
||||
//
|
||||
// 1. Download the backup with the specified name in a temp location
|
||||
// (this is in case of S3; otherwise it creates a temp copy of the zip)
|
||||
//
|
||||
// 2. Extract the backup in a temp directory inside the app "pb_data"
|
||||
// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
|
||||
//
|
||||
// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
|
||||
// under another temp sub dir that will be deleted on the next app start up
|
||||
// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
|
||||
// This is because on some environments it may not be allowed
|
||||
// to delete the currently open "pb_data" files.
|
||||
//
|
||||
// 4. Move the extracted dir content to the app "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.
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("restore is not supported on windows")
|
||||
}
|
||||
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
// fetch the backup file in a temp location
|
||||
br, err := fsys.GetFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
tempZip, err := os.CreateTemp(os.TempDir(), "pb_restore")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
|
||||
if _, err := io.Copy(tempZip, br); err != nil {
|
||||
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))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure that a database file exists
|
||||
extractedDB := filepath.Join(extractedDataDir, "data.db")
|
||||
if _, err := os.Stat(extractedDB); err != nil {
|
||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
||||
}
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
// root dir entries to exclude from the backup restore
|
||||
exclude := []string{LocalBackupsDirName, LocalTempDirName}
|
||||
|
||||
// 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))
|
||||
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)
|
||||
}
|
||||
|
||||
// move the extracted archive content to the app's pb_data
|
||||
if err := osutils.MoveDirContent(extractedDataDir, app.DataDir(), exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
|
||||
}
|
||||
|
||||
revertDataDirChanges := func() error {
|
||||
if err := osutils.MoveDirContent(app.DataDir(), extractedDataDir, exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
|
||||
}
|
||||
|
||||
if err := osutils.MoveDirContent(oldTempDataDir, app.DataDir(), exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restart the app
|
||||
if err := app.Restart(); err != nil {
|
||||
if err := revertDataDirChanges(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restart the app process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initAutobackupHooks registers the autobackup app serve hooks.
|
||||
// @todo add tests
|
||||
func (app *BaseApp) initAutobackupHooks() error {
|
||||
c := cron.New()
|
||||
isServe := false
|
||||
|
||||
loadJob := func() {
|
||||
c.Stop()
|
||||
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
|
||||
return
|
||||
}
|
||||
|
||||
c.Add("@autobackup", rawSchedule, func() {
|
||||
autoPrefix := "@auto_pb_backup_"
|
||||
|
||||
name := fmt.Sprintf(
|
||||
"%s%s.zip",
|
||||
autoPrefix,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
if maxKeep == 0 {
|
||||
return // no explicit limit
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if maxKeep >= len(files) {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
// sort desc
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// keep only the most recent n auto backup files
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// restart the ticker
|
||||
c.Start()
|
||||
}
|
||||
|
||||
// load on app serve
|
||||
app.OnBeforeServe().Add(func(e *ServeEvent) error {
|
||||
isServe = true
|
||||
loadJob()
|
||||
return nil
|
||||
})
|
||||
|
||||
// stop the ticker on app termination
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
c.Stop()
|
||||
return nil
|
||||
})
|
||||
|
||||
// reload on app settings change
|
||||
app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
|
||||
p := e.Model.(*models.Param)
|
||||
if p == nil || p.Key != models.ParamAppSettings {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
|
||||
// create with auto generated name
|
||||
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
||||
t.Fatal("Failed to create a backup with autogenerated name")
|
||||
}
|
||||
|
||||
// create with custom name
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create a backup with custom name")
|
||||
}
|
||||
|
||||
// create new with the same name (aka. replace)
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create and replace a backup with the same name")
|
||||
}
|
||||
|
||||
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
|
||||
|
||||
entries, err := os.ReadDir(backupsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedFiles := []string{
|
||||
`^pb_backup_\w+\.zip$`,
|
||||
`^pb_backup_\w+\.zip.attrs$`,
|
||||
"custom",
|
||||
"custom.attrs",
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedFiles) {
|
||||
names := getEntryNames(entries)
|
||||
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
|
||||
}
|
||||
|
||||
for i, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
|
||||
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), ".attrs") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(backupsDir, entry.Name())
|
||||
|
||||
if err := verifyBackupContent(app, path); err != nil {
|
||||
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create a initial test backup to ensure that there are at least 1
|
||||
// backup file and that the generated zip doesn't contain the backups dir
|
||||
if err := app.CreateBackup(context.Background(), "initial"); err != nil {
|
||||
t.Fatal("Failed to create test initial backup")
|
||||
}
|
||||
|
||||
// create test backup
|
||||
if err := app.CreateBackup(context.Background(), "test"); err != nil {
|
||||
t.Fatal("Failed to create test backup")
|
||||
}
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
|
||||
// missing backup
|
||||
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
||||
t.Fatal("Expected missing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func verifyBackupContent(app core.App, path string) error {
|
||||
dir, err := os.MkdirTemp("", "backup_test")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if err := archive.Extract(path, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedRootEntries := []string{
|
||||
"storage",
|
||||
"data.db",
|
||||
"data.db-shm",
|
||||
"data.db-wal",
|
||||
"logs.db",
|
||||
"logs.db-shm",
|
||||
"logs.db-wal",
|
||||
".gitignore",
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedRootEntries) {
|
||||
names := getEntryNames(entries)
|
||||
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
|
||||
return fmt.Errorf("Didn't expect %q entry", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEntryNames(entries []fs.DirEntry) []string {
|
||||
names := make([]string, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
names[i] = entry.Name()
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
+35
-5
@@ -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,
|
||||
@@ -236,3 +236,33 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
t.Fatalf("Expected nil s3 filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
if localErr != nil {
|
||||
t.Fatal(localErr)
|
||||
}
|
||||
if local == nil {
|
||||
t.Fatal("Expected local backups filesystem instance, got nil")
|
||||
}
|
||||
|
||||
// misconfigured s3
|
||||
app.Settings().Backups.S3.Enabled = true
|
||||
s3, s3Err := app.NewBackupsFilesystem()
|
||||
if s3Err == nil {
|
||||
t.Fatal("Expected S3 error, got nil")
|
||||
}
|
||||
if s3 != nil {
|
||||
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *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
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+36
-8
@@ -3,18 +3,46 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func init() {
|
||||
// Registers the sqlite3 driver with a ConnectHook so that we can
|
||||
// initialize the default PRAGMAs.
|
||||
//
|
||||
// Note 1: we don't define the PRAGMA as part of the dsn string
|
||||
// because not all pragmas are available.
|
||||
//
|
||||
// Note 2: 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.
|
||||
sql.Register("pb_sqlite3",
|
||||
&sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
_, err := conn.Exec(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -16000;
|
||||
`, nil)
|
||||
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
|
||||
}
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("pb_sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -8,13 +8,13 @@ import (
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
|
||||
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+13
-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 (
|
||||
@@ -65,9 +69,15 @@ type BootstrapEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type TerminateEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
App App
|
||||
Router *echo.Echo
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
@@ -112,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")
|
||||
|
||||
+25
-4
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
@@ -166,9 +167,9 @@ func TestDeleteCollection(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
colEmpty := &models.Collection{}
|
||||
colUnsaved := &models.Collection{}
|
||||
|
||||
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
colAuth, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -187,6 +188,11 @@ func TestDeleteCollection(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colBase, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -201,12 +207,14 @@ func TestDeleteCollection(t *testing.T) {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{colEmpty, true},
|
||||
{colAuth, false},
|
||||
{colUnsaved, true},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{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
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -225,6 +233,19 @@ func TestDeleteCollection(t *testing.T) {
|
||||
if app.Dao().HasTable(s.model.Name) {
|
||||
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
|
||||
}
|
||||
|
||||
// check if the external auths were deleted
|
||||
if s.model.IsAuth() {
|
||||
var total int
|
||||
err := app.Dao().ExternalAuthQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.HashExp{"collectionId": s.model.Id}).
|
||||
Row(&total)
|
||||
|
||||
if err != nil || total > 0 {
|
||||
t.Fatalf("[%d] Expected external auths to be deleted, got %v (%v)", i, total, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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()
|
||||
|
||||
+5
-2
@@ -127,7 +127,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
|
||||
}
|
||||
|
||||
if !hasId {
|
||||
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
|
||||
return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -311,6 +311,7 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
|
||||
if field != nil {
|
||||
clone := *field
|
||||
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
|
||||
clone.Name = col.alias
|
||||
result[col.alias] = &queryField{
|
||||
field: &clone,
|
||||
@@ -462,6 +463,8 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
skip = false
|
||||
partType = "select"
|
||||
activeBuilder = &selectParts
|
||||
case "distinct":
|
||||
continue // ignore as it is not important for the identifiers parsing
|
||||
case "from":
|
||||
skip = false
|
||||
partType = "from"
|
||||
@@ -477,7 +480,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
partType = "join"
|
||||
activeBuilder = &joinParts
|
||||
case "_discard_":
|
||||
// do nothing...
|
||||
// skip following tokens
|
||||
skip = true
|
||||
default:
|
||||
isJoin := partType == "join"
|
||||
|
||||
+32
-2
@@ -395,8 +395,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
created created_alias,
|
||||
updated updated_alias,
|
||||
123 as custom
|
||||
from demo1
|
||||
`,
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeRelation,
|
||||
@@ -409,6 +408,37 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with distinct and reordered id column",
|
||||
`select distinct
|
||||
id as id2,
|
||||
id,
|
||||
123 as custom
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with aliasing the same field multiple times",
|
||||
`select
|
||||
a.id as id,
|
||||
a.text as alias1,
|
||||
a.text as alias2,
|
||||
b.text as alias3,
|
||||
b.text as alias4
|
||||
from demo1 a
|
||||
left join demo1 as b`,
|
||||
false,
|
||||
map[string]string{
|
||||
"alias1": schema.FieldTypeText,
|
||||
"alias2": schema.FieldTypeText,
|
||||
"alias3": schema.FieldTypeText,
|
||||
"alias4": schema.FieldTypeText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
+36
-4
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/ghupdate"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
)
|
||||
@@ -21,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,
|
||||
@@ -67,18 +92,24 @@ 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, ghupdate.Config{})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
return nil
|
||||
@@ -101,5 +132,6 @@ func defaultPublicDir() string {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
|
||||
|
||||
// BackupCreate is a request form for creating a new app backup.
|
||||
type BackupCreate struct {
|
||||
app core.App
|
||||
ctx context.Context
|
||||
|
||||
Name string `form:"name" json:"name"`
|
||||
}
|
||||
|
||||
// NewBackupCreate creates new BackupCreate request form.
|
||||
func NewBackupCreate(app core.App) *BackupCreate {
|
||||
return &BackupCreate{
|
||||
app: app,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext replaces the default form context with the provided one.
|
||||
func (form *BackupCreate) SetContext(ctx context.Context) {
|
||||
form.ctx = ctx
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *BackupCreate) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Length(1, 100),
|
||||
validation.Match(backupNameRegex),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *BackupCreate) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
if exists, err := fsys.Exists(v); err != nil || exists {
|
||||
return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and creates the app backup.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before creating the backup.
|
||||
func (form *BackupCreate) Submit(interceptors ...InterceptorFunc[string]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.Name, func(name string) error {
|
||||
return form.app.CreateBackup(form.ctx, name)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestBackupCreateValidateAndSubmit(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
backupName string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"invalid length",
|
||||
strings.Repeat("a", 97) + ".zip",
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"valid length + invalid format",
|
||||
strings.Repeat("a", 96),
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"valid length + valid format",
|
||||
strings.Repeat("a", 96) + ".zip",
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"auto generated name",
|
||||
"",
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
func() {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
form := forms.NewBackupCreate(app)
|
||||
form.Name = s.backupName
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve all created backup files
|
||||
files, err := fsys.List("")
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to retrieve backup files", s.name)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if total := len(files); total != 0 {
|
||||
t.Errorf("[%s] Didn't expected backup files, found %d", s.name, total)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if total := len(files); total != 1 {
|
||||
t.Errorf("[%s] Expected 1 backup file, got %d", s.name, total)
|
||||
return
|
||||
}
|
||||
|
||||
if s.backupName == "" {
|
||||
prefix := "pb_backup_"
|
||||
if !strings.HasPrefix(files[0].Key, prefix) {
|
||||
t.Errorf("[%s] Expected the backup file, to have prefix %q: %q", s.name, prefix, files[0].Key)
|
||||
}
|
||||
} else if s.backupName != files[0].Key {
|
||||
t.Errorf("[%s] Expected backup file %q, got %q", s.name, s.backupName, files[0].Key)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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 now 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
-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,8 +58,22 @@ 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()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// eagerly merge the application settings with the form ones
|
||||
if err := form.app.Settings().Merge(form.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// persists settings change
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
// try to revert app settings
|
||||
form.app.Settings().Merge(oldSettings)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,7 +87,6 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
|
||||
form.app.LogsDao().Vacuum()
|
||||
}
|
||||
|
||||
// merge the application settings with the form ones
|
||||
return form.app.Settings().Merge(form.Settings)
|
||||
return nil
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -24,56 +24,58 @@ func TestEmailSendValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
func() {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewTestEmailSend(app)
|
||||
form.Email = s.email
|
||||
form.Template = s.template
|
||||
form := forms.NewTestEmailSend(app)
|
||||
form.Email = s.email
|
||||
form.Template = s.template
|
||||
|
||||
result := form.Submit()
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
continue
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
continue
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
expectedEmails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedEmails = 0
|
||||
}
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
return
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if app.TestMailer.TotalSend != expectedEmails {
|
||||
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
|
||||
}
|
||||
expectedEmails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedEmails = 0
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
if app.TestMailer.TotalSend != expectedEmails {
|
||||
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
expectedContent := "Verify"
|
||||
if s.template == "password-reset" {
|
||||
expectedContent = "Reset password"
|
||||
} else if s.template == "email-change" {
|
||||
expectedContent = "Confirm new email"
|
||||
}
|
||||
if len(s.expectedErrors) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
|
||||
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
expectedContent := "Verify"
|
||||
if s.template == "password-reset" {
|
||||
expectedContent = "Reset password"
|
||||
} else if s.template == "email-change" {
|
||||
expectedContent = "Confirm new email"
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, expectedContent) {
|
||||
t.Errorf("(%d) Expected the email to contains %s, got \n%v", i, expectedContent, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
const (
|
||||
s3FilesystemStorage = "storage"
|
||||
s3FilesystemBackups = "backups"
|
||||
)
|
||||
|
||||
// TestS3Filesystem defines a S3 filesystem connection test.
|
||||
type TestS3Filesystem struct {
|
||||
app core.App
|
||||
|
||||
// The name of the filesystem - storage or backups
|
||||
Filesystem string `form:"filesystem" json:"filesystem"`
|
||||
}
|
||||
|
||||
// NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
|
||||
func NewTestS3Filesystem(app core.App) *TestS3Filesystem {
|
||||
return &TestS3Filesystem{app: app}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *TestS3Filesystem) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Filesystem,
|
||||
validation.Required,
|
||||
validation.In(s3FilesystemStorage, s3FilesystemBackups),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and performs a S3 filesystem connection test.
|
||||
func (form *TestS3Filesystem) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var s3Config settings.S3Config
|
||||
|
||||
if form.Filesystem == s3FilesystemBackups {
|
||||
s3Config = form.app.Settings().Backups.S3
|
||||
} else {
|
||||
s3Config = form.app.Settings().S3
|
||||
}
|
||||
|
||||
if !s3Config.Enabled {
|
||||
return errors.New("S3 storage filesystem is not enabled")
|
||||
}
|
||||
|
||||
fsys, err := filesystem.NewS3(
|
||||
s3Config.Bucket,
|
||||
s3Config.Region,
|
||||
s3Config.Endpoint,
|
||||
s3Config.AccessKey,
|
||||
s3Config.Secret,
|
||||
s3Config.ForcePathStyle,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize the S3 filesystem: %w", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fsys.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return fmt.Errorf("failed to upload a test file: %w", err)
|
||||
}
|
||||
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fsys.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return fmt.Errorf("failed to delete a test file: %w", errs[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestS3FilesystemValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
filesystem string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"empty filesystem",
|
||||
"",
|
||||
[]string{"filesystem"},
|
||||
},
|
||||
{
|
||||
"invalid filesystem",
|
||||
"something",
|
||||
[]string{"filesystem"},
|
||||
},
|
||||
{
|
||||
"backups filesystem",
|
||||
"backups",
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"storage filesystem",
|
||||
"storage",
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = s.filesystem
|
||||
|
||||
result := form.Validate()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3FilesystemSubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// check if validate was called
|
||||
{
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = ""
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if _, ok := result.(validation.Errors); !ok {
|
||||
t.Fatalf("Expected validation.Error, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// check with valid storage and disabled s3
|
||||
{
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = "storage"
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if _, ok := result.(validation.Error); ok {
|
||||
t.Fatalf("Didn't expect validation.Error, got %v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,88 +3,92 @@ module github.com/pocketbase/pocketbase
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/aws/aws-sdk-go v1.44.247
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/aws/aws-sdk-go v1.44.313
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.0
|
||||
github.com/dop251/goja v0.0.0-20221118162653-d4bf6fde1b86
|
||||
github.com/dop251/goja_nodejs v0.0.0-20221009164102-3aa5028e57f6
|
||||
github.com/domodwyer/mailyak/v3 v3.6.1
|
||||
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/mattn/go-sqlite3 v1.14.16
|
||||
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/spf13/cast v1.5.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.29.0
|
||||
golang.org/x/crypto v0.8.0
|
||||
golang.org/x/net v0.9.0
|
||||
golang.org/x/oauth2 v0.7.0
|
||||
modernc.org/sqlite v1.21.2
|
||||
gocloud.dev v0.32.0
|
||||
golang.org/x/crypto v0.11.0
|
||||
golang.org/x/net v0.12.0
|
||||
golang.org/x/oauth2 v0.10.0
|
||||
golang.org/x/sync v0.3.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.17.8 // 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.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.63 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.33 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.24 // 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.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.32.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.18.9 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.20.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.75 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.32 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.13.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.21.0 // indirect
|
||||
github.com/aws/smithy-go v1.14.0 // 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-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.8.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
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.18 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
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.7.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
golang.org/x/term v0.7.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/image v0.9.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.10.0 // indirect
|
||||
golang.org/x/term v0.10.0 // indirect
|
||||
golang.org/x/text v0.11.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.8.0 // indirect
|
||||
golang.org/x/tools v0.11.1 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.119.0 // indirect
|
||||
google.golang.org/api v0.134.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
|
||||
google.golang.org/grpc v1.54.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230731193218-e0aa005b6bdf // 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.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.4 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.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.6.0 // indirect
|
||||
modernc.org/memory v1.6.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.1.0 // 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(
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -90,6 +91,15 @@ func init() {
|
||||
return tablesErr
|
||||
}
|
||||
|
||||
dao := daos.New(db)
|
||||
|
||||
// inserts default settings
|
||||
// -----------------------------------------------------------
|
||||
defaultSettings := settings.New()
|
||||
if err := dao.SaveSettings(defaultSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// inserts the system profiles collection
|
||||
// -----------------------------------------------------------
|
||||
usersCollection := &models.Collection{}
|
||||
@@ -139,7 +149,7 @@ func init() {
|
||||
},
|
||||
)
|
||||
|
||||
return daos.New(db).SaveCollection(usersCollection)
|
||||
return dao.SaveCollection(usersCollection)
|
||||
}, func(db dbx.Builder) error {
|
||||
tables := []string{
|
||||
"users",
|
||||
|
||||
@@ -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,20 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// Cleanup dangling deleted collections references
|
||||
// (see https://github.com/pocketbase/pocketbase/discussions/2570).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
_, err := db.NewQuery(`
|
||||
DELETE FROM {{_externalAuths}}
|
||||
WHERE [[collectionId]] NOT IN (SELECT [[id]] FROM {{_collections}})
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}, func(db dbx.Builder) error {
|
||||
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,9 @@
|
||||
package models
|
||||
|
||||
import "github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
type BackupFileInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
Modified types.DateTime `json:"modified"`
|
||||
}
|
||||
+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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +194,7 @@ func (f SchemaField) Validate() error {
|
||||
|
||||
excludeNames := BaseModelFieldNames()
|
||||
// exclude special filter literals
|
||||
excludeNames = append(excludeNames, "null", "true", "false")
|
||||
excludeNames = append(excludeNames, "null", "true", "false", "_rowid_")
|
||||
// exclude system literals
|
||||
excludeNames = append(excludeNames, SystemFieldNames()...)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -313,6 +325,15 @@ func TestSchemaFieldValidate(t *testing.T) {
|
||||
},
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"reserved name (_rowid_)",
|
||||
schema.SchemaField{
|
||||
Type: schema.FieldTypeText,
|
||||
Id: "1234567890",
|
||||
Name: "_rowid_",
|
||||
},
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"reserved name (id)",
|
||||
schema.SchemaField{
|
||||
@@ -2112,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{
|
||||
@@ -2128,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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -23,10 +24,11 @@ const SecretMask string = "******"
|
||||
type Settings struct {
|
||||
mux sync.RWMutex
|
||||
|
||||
Meta MetaConfig `form:"meta" json:"meta"`
|
||||
Logs LogsConfig `form:"logs" json:"logs"`
|
||||
Smtp SmtpConfig `form:"smtp" json:"smtp"`
|
||||
S3 S3Config `form:"s3" json:"s3"`
|
||||
Meta MetaConfig `form:"meta" json:"meta"`
|
||||
Logs LogsConfig `form:"logs" json:"logs"`
|
||||
Smtp SmtpConfig `form:"smtp" json:"smtp"`
|
||||
S3 S3Config `form:"s3" json:"s3"`
|
||||
Backups BackupsConfig `form:"backups" json:"backups"`
|
||||
|
||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||
@@ -58,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.
|
||||
@@ -84,6 +89,9 @@ func New() *Settings {
|
||||
Password: "",
|
||||
Tls: false,
|
||||
},
|
||||
Backups: BackupsConfig{
|
||||
CronMaxKeep: 3,
|
||||
},
|
||||
AdminAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days
|
||||
@@ -94,7 +102,7 @@ func New() *Settings {
|
||||
},
|
||||
AdminFileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 300, // 5 minutes
|
||||
Duration: 120, // 2 minutes
|
||||
},
|
||||
RecordAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -110,7 +118,7 @@ func New() *Settings {
|
||||
},
|
||||
RecordFileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 300, // 5 minutes
|
||||
Duration: 120, // 2 minutes
|
||||
},
|
||||
RecordEmailChangeToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -170,6 +178,15 @@ func New() *Settings {
|
||||
AppleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
InstagramAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
VKAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
YandexAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +208,7 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.RecordFileToken),
|
||||
validation.Field(&s.Smtp),
|
||||
validation.Field(&s.S3),
|
||||
validation.Field(&s.Backups),
|
||||
validation.Field(&s.GoogleAuth),
|
||||
validation.Field(&s.FacebookAuth),
|
||||
validation.Field(&s.GithubAuth),
|
||||
@@ -209,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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,6 +266,7 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
sensitiveFields := []*string{
|
||||
&clone.Smtp.Password,
|
||||
&clone.S3.Secret,
|
||||
&clone.Backups.S3.Secret,
|
||||
&clone.AdminAuthToken.Secret,
|
||||
&clone.AdminPasswordResetToken.Secret,
|
||||
&clone.AdminFileToken.Secret,
|
||||
@@ -271,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
|
||||
@@ -308,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +421,51 @@ func (c S3Config) Validate() error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type BackupsConfig struct {
|
||||
// Cron is a cron expression to schedule auto backups, eg. "* * * * *".
|
||||
//
|
||||
// Leave it empty to disable the auto backups functionality.
|
||||
Cron string `form:"cron" json:"cron"`
|
||||
|
||||
// CronMaxKeep is the the max number of cron generated backups to
|
||||
// keep before removing older entries.
|
||||
//
|
||||
// This field works only when the cron config has valid cron expression.
|
||||
CronMaxKeep int `form:"cronMaxKeep" json:"cronMaxKeep"`
|
||||
|
||||
// S3 is an optional S3 storage config specifying where to store the app backups.
|
||||
S3 S3Config `form:"s3" json:"s3"`
|
||||
}
|
||||
|
||||
// Validate makes BackupsConfig validatable by implementing [validation.Validatable] interface.
|
||||
func (c BackupsConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.S3),
|
||||
validation.Field(&c.Cron, validation.By(checkCronExpression)),
|
||||
validation.Field(
|
||||
&c.CronMaxKeep,
|
||||
validation.When(c.Cron != "", validation.Required),
|
||||
validation.Min(1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func checkCronExpression(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
_, err := cron.NewSchedule(v)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_cron", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MetaConfig struct {
|
||||
AppName string `form:"appName" json:"appName"`
|
||||
AppUrl string `form:"appUrl" json:"appUrl"`
|
||||
|
||||
@@ -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)
|
||||
@@ -127,6 +136,7 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.Smtp.Enabled = true
|
||||
s2.S3.Enabled = true
|
||||
s2.S3.Endpoint = "test"
|
||||
s2.Backups.Cron = "* * * * *"
|
||||
s2.AdminAuthToken.Duration = 1
|
||||
s2.AdminPasswordResetToken.Duration = 2
|
||||
s2.AdminFileToken.Duration = 2
|
||||
@@ -171,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)
|
||||
@@ -231,6 +247,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
// secrets
|
||||
s1.Smtp.Password = testSecret
|
||||
s1.S3.Secret = testSecret
|
||||
s1.Backups.S3.Secret = testSecret
|
||||
s1.AdminAuthToken.Secret = testSecret
|
||||
s1.AdminPasswordResetToken.Secret = testSecret
|
||||
s1.AdminFileToken.Secret = testSecret
|
||||
@@ -257,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 {
|
||||
@@ -312,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()
|
||||
|
||||
@@ -340,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) {
|
||||
@@ -610,6 +636,74 @@ func TestMetaConfigValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
config settings.BackupsConfig
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"zero value",
|
||||
settings.BackupsConfig{},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"invalid cron",
|
||||
settings.BackupsConfig{
|
||||
Cron: "invalid",
|
||||
CronMaxKeep: 0,
|
||||
},
|
||||
[]string{"cron", "cronMaxKeep"},
|
||||
},
|
||||
{
|
||||
"invalid enabled S3",
|
||||
settings.BackupsConfig{
|
||||
S3: settings.S3Config{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
[]string{"s3"},
|
||||
},
|
||||
{
|
||||
"valid data",
|
||||
settings.BackupsConfig{
|
||||
S3: settings.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "example.com",
|
||||
Bucket: "test",
|
||||
Region: "test",
|
||||
AccessKey: "test",
|
||||
Secret: "test",
|
||||
},
|
||||
Cron: "*/10 * * * *",
|
||||
CronMaxKeep: 1,
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := s.config.Validate()
|
||||
|
||||
// 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)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailTemplateValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
emailTemplate settings.EmailTemplate
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// HttpClient is a base HTTP client interface (usually used for test purposes).
|
||||
type HttpClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// Config defines the config options of the ghupdate plugin.
|
||||
//
|
||||
// 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
|
||||
|
||||
// Repo specifies the name of the repository (default to "pocketbase").
|
||||
Repo string
|
||||
|
||||
// ArchiveExecutable specifies the name of the executable file in the release archive
|
||||
// (default to "pocketbase"; an additional ".exe" check is also performed as a fallback).
|
||||
ArchiveExecutable string
|
||||
|
||||
// Optional context to use when fetching and downloading the latest release.
|
||||
Context context.Context
|
||||
|
||||
// The HTTP client to use when fetching and downloading the latest release.
|
||||
// Defaults to `http.DefaultClient`.
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
// MustRegister registers the ghupdate plugin to the provided app instance
|
||||
// and panic if it fails.
|
||||
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, config Config) error {
|
||||
p := &plugin{
|
||||
app: app,
|
||||
currentVersion: rootCmd.Version,
|
||||
config: config,
|
||||
}
|
||||
|
||||
if p.config.Owner == "" {
|
||||
p.config.Owner = "pocketbase"
|
||||
}
|
||||
|
||||
if p.config.Repo == "" {
|
||||
p.config.Repo = "pocketbase"
|
||||
}
|
||||
|
||||
if p.config.ArchiveExecutable == "" {
|
||||
p.config.ArchiveExecutable = "pocketbase"
|
||||
}
|
||||
|
||||
if p.config.HttpClient == nil {
|
||||
p.config.HttpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
if p.config.Context == nil {
|
||||
p.config.Context = context.Background()
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(p.updateCmd())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
app core.App
|
||||
currentVersion string
|
||||
config Config
|
||||
}
|
||||
|
||||
func (p *plugin) updateCmd() *cobra.Command {
|
||||
var withBackup bool
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Automatically updates the current PocketBase executable with the latest available version",
|
||||
// @todo remove after logs generalization
|
||||
// prevents printing the error log twice
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
command.PersistentFlags().BoolVar(
|
||||
&withBackup,
|
||||
"backup",
|
||||
true,
|
||||
"Creates a pb_data backup at the end of the update process",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func (p *plugin) update(withBackup bool) error {
|
||||
color.Yellow("Fetching release information...")
|
||||
|
||||
latest, err := fetchLatestRelease(
|
||||
p.config.Context,
|
||||
p.config.HttpClient,
|
||||
p.config.Owner,
|
||||
p.config.Repo,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
|
||||
color.Green("You already have the latest PocketBase %s.", p.currentVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
|
||||
if suffix == "" {
|
||||
return errors.New("unsupported platform")
|
||||
}
|
||||
|
||||
asset, err := latest.findAssetBySuffix(suffix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
releaseDir := filepath.Join(p.app.DataDir(), core.LocalTempDirName)
|
||||
defer os.RemoveAll(releaseDir)
|
||||
|
||||
color.Yellow("Downloading %s...", asset.Name)
|
||||
|
||||
// download the release asset
|
||||
assetZip := filepath.Join(releaseDir, asset.Name)
|
||||
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Yellow("Extracting %s...", asset.Name)
|
||||
|
||||
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
if err := archive.Extract(assetZip, extractDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Yellow("Replacing the executable...")
|
||||
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
renamedOldExec := oldExec + ".old"
|
||||
defer os.Remove(renamedOldExec)
|
||||
|
||||
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
|
||||
if _, err := os.Stat(newExec); err != nil {
|
||||
// try again with an .exe extension
|
||||
newExec = newExec + ".exe"
|
||||
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
|
||||
return fmt.Errorf("The executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
// rename the current executable
|
||||
if err := os.Rename(oldExec, renamedOldExec); err != nil {
|
||||
return fmt.Errorf("Failed to rename the current executable: %w", err)
|
||||
}
|
||||
|
||||
tryToRevertExecChanges := func() {
|
||||
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
|
||||
log.Println(revertErr)
|
||||
}
|
||||
}
|
||||
|
||||
// replace with the extracted binary
|
||||
if err := os.Rename(newExec, oldExec); err != nil {
|
||||
tryToRevertExecChanges()
|
||||
return fmt.Errorf("Failed replacing the executable: %w", err)
|
||||
}
|
||||
|
||||
if withBackup {
|
||||
color.Yellow("Creating pb_data backup...")
|
||||
|
||||
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
|
||||
if err := p.app.CreateBackup(p.config.Context, backupName); err != nil {
|
||||
tryToRevertExecChanges()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
color.HiBlack("---")
|
||||
color.Green("Update completed successfully! You can start the executable as usual.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchLatestRelease(
|
||||
ctx context.Context,
|
||||
client HttpClient,
|
||||
owner string,
|
||||
repo string,
|
||||
) (*release, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
rawBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// http.Client doesn't treat non 2xx responses as error
|
||||
if res.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf(
|
||||
"(%d) failed to fetch latest releases:\n%s",
|
||||
res.StatusCode,
|
||||
string(rawBody),
|
||||
)
|
||||
}
|
||||
|
||||
result := &release{}
|
||||
if err := json.Unmarshal(rawBody, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func downloadFile(
|
||||
ctx context.Context,
|
||||
client HttpClient,
|
||||
url string,
|
||||
destPath string,
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
// http.Client doesn't treat non 2xx responses as error
|
||||
if res.StatusCode >= 400 {
|
||||
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
|
||||
}
|
||||
|
||||
// ensure that the dest parent dir(s) exist
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
if _, err := io.Copy(dest, res.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func archiveSuffix(goos, goarch string) string {
|
||||
switch goos {
|
||||
case "linux":
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "_linux_amd64.zip"
|
||||
case "arm64":
|
||||
return "_linux_arm64.zip"
|
||||
case "arm":
|
||||
return "_linux_armv7.zip"
|
||||
}
|
||||
case "darwin":
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "_darwin_amd64.zip"
|
||||
case "arm64":
|
||||
return "_darwin_arm64.zip"
|
||||
}
|
||||
case "windows":
|
||||
switch goarch {
|
||||
case "amd64":
|
||||
return "_windows_amd64.zip"
|
||||
case "arm64":
|
||||
return "_windows_arm64.zip"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func compareVersions(a, b string) int {
|
||||
aSplit := strings.Split(a, ".")
|
||||
aTotal := len(aSplit)
|
||||
|
||||
bSplit := strings.Split(b, ".")
|
||||
bTotal := len(bSplit)
|
||||
|
||||
limit := aTotal
|
||||
if bTotal > aTotal {
|
||||
limit = bTotal
|
||||
}
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
var x, y int
|
||||
|
||||
if i < aTotal {
|
||||
x, _ = strconv.Atoi(aSplit[i])
|
||||
}
|
||||
|
||||
if i < bTotal {
|
||||
y, _ = strconv.Atoi(bSplit[i])
|
||||
}
|
||||
|
||||
if x < y {
|
||||
return 1 // b is newer
|
||||
}
|
||||
|
||||
if x > y {
|
||||
return -1 // a is newer
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ghupdate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
a string
|
||||
b string
|
||||
expected int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"0", "", 0},
|
||||
{"1", "1.0.0", 0},
|
||||
{"1.1", "1.1.0", 0},
|
||||
{"1.1", "1.1.1", 1},
|
||||
{"1.1", "1.0.1", -1},
|
||||
{"1.0", "1.0.1", 1},
|
||||
{"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},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
result := compareVersions(s.a, s.b)
|
||||
|
||||
if result != s.expected {
|
||||
t.Fatalf("[%d] Expected %q vs %q to result in %d, got %d", i, s.a, s.b, s.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ghupdate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type releaseAsset struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
DownloadUrl string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type release struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag_name"`
|
||||
Published string `json:"published_at"`
|
||||
Url string `json:"html_url"`
|
||||
Assets []*releaseAsset `json:"assets"`
|
||||
}
|
||||
|
||||
// findAssetBySuffix returns the first available asset containing the specified suffix.
|
||||
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
|
||||
if suffix != "" {
|
||||
for _, asset := range r.Assets {
|
||||
if strings.HasSuffix(asset.Name, suffix) {
|
||||
return asset, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("missing asset containing " + suffix)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ghupdate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReleaseFindAssetBySuffix(t *testing.T) {
|
||||
r := release{
|
||||
Assets: []*releaseAsset{
|
||||
{Name: "test1.zip", Id: 1},
|
||||
{Name: "test2.zip", Id: 2},
|
||||
{Name: "test22.zip", Id: 22},
|
||||
{Name: "test3.zip", Id: 3},
|
||||
},
|
||||
}
|
||||
|
||||
asset, err := r.findAssetBySuffix("2.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil, got err: %v", err)
|
||||
}
|
||||
|
||||
if asset.Id != 2 {
|
||||
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
|
||||
}
|
||||
}
|
||||
@@ -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("registerHooks: %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
|
||||
}
|
||||
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user