mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2cd5a534 | ||
|
|
5d8fc939e2 | ||
|
|
1a5180d7d3 | ||
|
|
8a08a4764d | ||
|
|
66b317f01c | ||
|
|
ab5a770346 | ||
|
|
65697add43 | ||
|
|
841415f0ff | ||
|
|
f295ce9403 | ||
|
|
383b2a1279 | ||
|
|
73fb12c2bc | ||
|
|
571c4dcc8d | ||
|
|
f56adf26f4 | ||
|
|
f8f3ca25ee | ||
|
|
e01f76d37b | ||
|
|
47fc9b1066 | ||
|
|
eaf08a5c15 | ||
|
|
8ef3d4e966 | ||
|
|
04e0cec32c | ||
|
|
9a231ba7b3 | ||
|
|
7f959011b8 | ||
|
|
994761b728 | ||
|
|
f19b9e3552 | ||
|
|
36783b8f04 | ||
|
|
a076cc906f | ||
|
|
b1a30f4050 | ||
|
|
4506fb17e9 | ||
|
|
72f72bc84f | ||
|
|
7fd5102fb5 | ||
|
|
d6bdc51009 | ||
|
|
789373d15d | ||
|
|
2dc000da65 | ||
|
|
1095637bcd | ||
|
|
c4fcba5210 | ||
|
|
d8c8289269 | ||
|
|
dbbfa243bc | ||
|
|
a16b0c9004 | ||
|
|
03a7bafa66 | ||
|
|
d129959098 | ||
|
|
6749559a22 | ||
|
|
28bc2678e9 | ||
|
|
d4202e696b | ||
|
|
9de3cc99a0 | ||
|
|
111bc59472 | ||
|
|
b2647ebca9 | ||
|
|
ef226cf9c1 | ||
|
|
63d5a8d633 | ||
|
|
d71c3cd19c | ||
|
|
240bd6790a | ||
|
|
05a4071eba | ||
|
|
ce857985be | ||
|
|
320d1482a4 | ||
|
|
46399dddac | ||
|
|
52c288d9db | ||
|
|
ed741662b2 | ||
|
|
0c14f32822 | ||
|
|
ec0d3b0d3d | ||
|
|
10d7faea31 | ||
|
|
f62664098e | ||
|
|
1dfc314bc1 | ||
|
|
7b2d88fa30 | ||
|
|
72cb2d3f43 | ||
|
|
0739e90ff2 | ||
|
|
460c684caa | ||
|
|
d64fbf9011 | ||
|
|
dfd9528847 | ||
|
|
d4d425c61d | ||
|
|
0f0c00bd03 |
@@ -29,6 +29,14 @@ jobs:
|
||||
- name: Build Admin dashboard UI
|
||||
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
||||
|
||||
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
||||
# https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - name: Run linter
|
||||
# uses: golangci/golangci-lint-action@v3
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v3
|
||||
with:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/.vscode/
|
||||
.idea
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ before:
|
||||
builds:
|
||||
- main: ./examples/base
|
||||
binary: pocketbase
|
||||
ldflags:
|
||||
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
Contributing to PocketBase
|
||||
======================================================================
|
||||
|
||||
Thanks for taking the time to improve PocketBase!
|
||||
|
||||
This document describes how to prepare a PR for a change in the main repository.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.18+ (for making changes in the Go code)
|
||||
- Node 16+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
```
|
||||
git clone https://github.com/your_username/pocketbase.git
|
||||
```
|
||||
|
||||
> It is recommended to create a new branch from master for each of your bugfixes and features.
|
||||
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
|
||||
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
|
||||
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
|
||||
|
||||
1. Navigate to `examples/base`
|
||||
2. Run `go run main.go serve`
|
||||
|
||||
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
|
||||
|
||||
**Before making a PR to the main repository, it is a good idea to:**
|
||||
|
||||
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
|
||||
To run the tests, you could execute (while in the root project directory):
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
|
||||
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
|
||||
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
|
||||
|
||||
To start the Admin UI:
|
||||
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
3. Start vite's dev server
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
|
||||
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
|
||||
@@ -0,0 +1,9 @@
|
||||
lint:
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
test:
|
||||
go test -v --cover ./...
|
||||
|
||||
test-report:
|
||||
go test -v --cover -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Although the web API defintions are considered stable,
|
||||
> ⚠️ Although the web API definitions are considered stable,
|
||||
> please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
@@ -43,6 +43,7 @@ your own custom app specific business logic and still have a single portable exe
|
||||
# go 1.18+
|
||||
go get github.com/pocketbase/pocketbase
|
||||
```
|
||||
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
|
||||
|
||||
### Example
|
||||
|
||||
@@ -90,10 +91,12 @@ Running/building the application is the same as for any other Go program, aka. j
|
||||
|
||||
**PocketBase embeds SQLite, but doesn't require CGO.**
|
||||
|
||||
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
|
||||
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` enviroment variable to `1` or `0` respectively.
|
||||
|
||||
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
|
||||
|
||||
### Testing
|
||||
|
||||
PocketBase comes with mixed bag of unit and integration tests.
|
||||
@@ -117,8 +120,9 @@ PocketBase is free and open source project licensed under the [MIT License](LICE
|
||||
|
||||
You could help continuing its development by:
|
||||
|
||||
- [Suggest new features, report issues and fix bugs](https://github.com/pocketbase/pocketbase/issues)
|
||||
- [Contribute to the source code](CONTRIBUTING.md)
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
> Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3.
|
||||
|
||||
+31
-23
@@ -24,7 +24,7 @@ func BindAdminApi(app core.App, rg *echo.Group) {
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
|
||||
subGroup.GET("/:id", api.view, RequireAdminAuth())
|
||||
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
|
||||
subGroup.DELETE("/:id", api.delete, RequireAdminAuth())
|
||||
@@ -66,7 +66,7 @@ func (api *adminApi) refresh(c echo.Context) error {
|
||||
func (api *adminApi) emailAuth(c echo.Context) error {
|
||||
form := forms.NewAdminLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
@@ -80,11 +80,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
|
||||
func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
form := forms.NewAdminPasswordResetRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while validating the form.", err)
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show the result
|
||||
@@ -101,7 +101,7 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
form := forms.NewAdminPasswordResetConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
@@ -166,7 +166,7 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminCreateEvent{
|
||||
@@ -174,20 +174,24 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
Admin: admin,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
// create the admin
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
// create the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) update(c echo.Context) error {
|
||||
@@ -205,7 +209,7 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminUpdateEvent{
|
||||
@@ -213,20 +217,24 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
Admin: admin,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
// update the admin
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
// update the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) delete(c echo.Context) error {
|
||||
|
||||
+50
-18
@@ -4,11 +4,14 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestAdminAuth(t *testing.T) {
|
||||
@@ -92,6 +95,7 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
@@ -99,22 +103,34 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
// usually this events are fired but since the submit is
|
||||
// executed in a separate go routine they are fired async
|
||||
// ExpectedEvents: map[string]int{
|
||||
// "OnModelBeforeUpdate": 1,
|
||||
// "OnModelAfterUpdate": 1,
|
||||
// "OnMailerBeforeUserResetPasswordSend:1": 1,
|
||||
// "OnMailerAfterUserResetPasswordSend:1": 1,
|
||||
// },
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing admin (after already sent)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent password request
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
|
||||
if err := dao.Save(admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -456,12 +472,37 @@ func TestAdminDelete(t *testing.T) {
|
||||
func TestAdminCreate(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Name: "unauthorized (while having at least 1 existing admin)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized (while having 0 existing admins)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// delete all admins
|
||||
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":`,
|
||||
`"email":"testnew@example.com"`,
|
||||
`"avatar":3`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPost,
|
||||
@@ -482,9 +523,6 @@ func TestAdminCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid data format",
|
||||
@@ -507,9 +545,6 @@ func TestAdminCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
@@ -622,9 +657,6 @@ func TestAdminUpdate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
|
||||
+81
-10
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// InitApi creates a configured echo instance with registered
|
||||
@@ -38,13 +39,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
var apiErr *rest.ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case (*echo.HTTPError):
|
||||
case *echo.HTTPError:
|
||||
if v.Internal != nil && app.IsDebug() {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = rest.NewApiError(v.Code, msg, v)
|
||||
case (*rest.ApiError):
|
||||
case *rest.ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
}
|
||||
@@ -67,17 +68,12 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if cErr != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
log.Println(cErr)
|
||||
}
|
||||
}
|
||||
|
||||
// serves /ui/dist/index.html file
|
||||
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
|
||||
e.FileFS("/_", "index.html", ui.DistIndexHTML, middleware.Gzip())
|
||||
|
||||
// serves static files from the /ui/dist directory
|
||||
// (similar to echo.StaticFS but with gzip middleware enabled)
|
||||
e.GET("/_/*", StaticDirectoryHandler(ui.DistDirFS, false), middleware.Gzip())
|
||||
// admin ui routes
|
||||
bindStaticAdminUI(app, e)
|
||||
|
||||
// default routes
|
||||
api := e.Group("/api")
|
||||
@@ -129,3 +125,78 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.H
|
||||
return c.FileFS(name, fileSystem)
|
||||
}
|
||||
}
|
||||
|
||||
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
|
||||
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
// serves /ui/dist/index.html file
|
||||
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
|
||||
e.FileFS(
|
||||
"/_",
|
||||
"index.html",
|
||||
ui.DistIndexHTML,
|
||||
middleware.Gzip(),
|
||||
installerRedirect(app),
|
||||
)
|
||||
|
||||
// serves static files from the /ui/dist directory
|
||||
// (similar to echo.StaticFS but with gzip middleware enabled)
|
||||
e.GET(
|
||||
"/_/*",
|
||||
StaticDirectoryHandler(ui.DistDirFS, false),
|
||||
middleware.Gzip(),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer admin UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
// keep totalAdminsCacheKey value up-to-date
|
||||
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
})
|
||||
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
})
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// load into cache (if not already)
|
||||
if !app.Cache().Has(totalAdminsCacheKey) {
|
||||
if err := updateTotalAdminsCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
|
||||
|
||||
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
||||
|
||||
if totalAdmins == 0 && !hasInstallerParam {
|
||||
// redirect to the installer page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "/_/?installer#")
|
||||
}
|
||||
|
||||
if totalAdmins != 0 && hasInstallerParam {
|
||||
// redirect to the home page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-44
@@ -1,8 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -76,9 +74,9 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
|
||||
form := forms.NewCollectionUpsert(api.app, collection)
|
||||
|
||||
// read
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionCreateEvent{
|
||||
@@ -86,20 +84,24 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
Collection: collection,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
// submit
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) update(c echo.Context) error {
|
||||
@@ -110,9 +112,9 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
|
||||
form := forms.NewCollectionUpsert(api.app, collection)
|
||||
|
||||
// read
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionUpdateEvent{
|
||||
@@ -120,20 +122,24 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
Collection: collection,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
// submit
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) delete(c echo.Context) error {
|
||||
@@ -152,13 +158,6 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
}
|
||||
|
||||
// try to delete the collection files
|
||||
if err := api.deleteCollectionFiles(e.Collection); err != nil && api.app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
@@ -168,18 +167,3 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) deleteCollectionFiles(collection *models.Collection) error {
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
failed := fs.DeletePrefix(collection.BaseFilesPath())
|
||||
if len(failed) > 0 {
|
||||
return errors.New("Failed to delete all record files.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -297,9 +297,6 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":{"code":"validation_required"`,
|
||||
`"schema":{"code":"validation_required"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid data (eg. existing name)",
|
||||
@@ -315,9 +312,6 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":{"code":"validation_collection_name_exists"`,
|
||||
`"schema":{"0":{"name":{"code":"validation_required"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
@@ -399,9 +393,6 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"name":{"code":"validation_collection_name_exists"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
|
||||
+2
-1
@@ -1,13 +1,14 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestFileDownload(t *testing.T) {
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
|
||||
+25
-3
@@ -77,6 +77,28 @@ func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminAuthOnlyIfAny middleware requires a request to have
|
||||
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
|
||||
// ONLY if the application has at least 1 existing Admin model.
|
||||
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to fetch admins info.", err)
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
if admin != nil || totalAdmins == 0 {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminOrUserAuth middleware requires a request to have
|
||||
// a valid admin or user Authorization header set
|
||||
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
|
||||
@@ -204,11 +226,11 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
if err != nil {
|
||||
switch v := err.(type) {
|
||||
case (*echo.HTTPError):
|
||||
case *echo.HTTPError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case (*rest.ApiError):
|
||||
case *rest.ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
@@ -259,7 +281,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
// ---
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
|
||||
daysDiff := (now.Sub(lastLogsDeletedAt).Hours() * 24)
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
|
||||
if daysDiff > float64(app.Settings().Logs.MaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
|
||||
|
||||
@@ -291,6 +291,125 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest (while having at least 1 existing admin)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminAuthOnlyIfAny(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "guest (while having 0 existing admins)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// delete all admins
|
||||
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminAuthOnlyIfAny(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminAuthOnlyIfAny(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminAuthOnlyIfAny(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
},
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminAuthOnlyIfAny(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
|
||||
+36
-27
@@ -159,22 +159,23 @@ func (api *realtimeApi) bindEvents() {
|
||||
adminTable := (&models.Admin{}).TableName()
|
||||
|
||||
// update user/admin auth state
|
||||
api.app.OnModelAfterUpdate().Add(func(data *core.ModelEvent) error {
|
||||
modelTable := data.Model.TableName()
|
||||
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
if modelTable == userTable {
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
} else if modelTable == adminTable {
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
} else {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == data.Model.GetId() {
|
||||
client.Set(contextKey, data.Model)
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
client.Set(contextKey, e.Model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,21 +183,22 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
|
||||
// remove user/admin client(s)
|
||||
api.app.OnModelAfterDelete().Add(func(data *core.ModelEvent) error {
|
||||
modelTable := data.Model.TableName()
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
if modelTable == userTable {
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
} else if modelTable == adminTable {
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
} else {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == data.Model.GetId() {
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
}
|
||||
@@ -204,18 +206,20 @@ func (api *realtimeApi) bindEvents() {
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnRecordAfterCreateRequest().Add(func(data *core.RecordCreateEvent) error {
|
||||
api.broadcastRecord("create", data.Record)
|
||||
api.app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
|
||||
api.broadcastRecord("create", e.Record)
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnRecordAfterUpdateRequest().Add(func(data *core.RecordUpdateEvent) error {
|
||||
api.broadcastRecord("update", data.Record)
|
||||
api.app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
|
||||
api.broadcastRecord("update", e.Record)
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnRecordAfterDeleteRequest().Add(func(data *core.RecordDeleteEvent) error {
|
||||
api.broadcastRecord("delete", data.Record)
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("delete", record)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -284,19 +288,24 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// remove the expand from the broadcasted record because we don't
|
||||
// know if the clients have access to view the expanded records
|
||||
cleanRecord := *record
|
||||
cleanRecord.SetExpand(nil)
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + record.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + record.Id): collection.ViewRule,
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
}
|
||||
|
||||
recordData := &recordData{
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: record,
|
||||
Record: &cleanRecord,
|
||||
}
|
||||
|
||||
serializedData, err := json.Marshal(recordData)
|
||||
serializedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
@@ -310,7 +319,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
continue
|
||||
}
|
||||
|
||||
if !api.canAccessRecord(client, record, rule) {
|
||||
if !api.canAccessRecord(client, data.Record, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+83
-61
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
@@ -52,10 +52,9 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return rest.NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// forbid user/guest defined non-relational joins (aka. @collection.*)
|
||||
queryStr := c.QueryString()
|
||||
if admin == nil && queryStr != "" && (strings.Contains(queryStr, "@collection") || strings.Contains(queryStr, "%40collection")) {
|
||||
return rest.NewForbiddenError("Only admins can filter by @collection.", nil)
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
@@ -63,14 +62,15 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
Query(api.app.Dao().RecordQuery(collection)).
|
||||
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
|
||||
|
||||
if admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(queryStr, &rawRecords)
|
||||
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
@@ -80,13 +80,13 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
// expand records relations
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
expandErr := api.app.Dao().ExpandRecords(
|
||||
failed := api.app.Dao().ExpandRecords(
|
||||
records,
|
||||
expands,
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if expandErr != nil && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", expandErr)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,16 +141,14 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return rest.NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
expandErr := api.app.Dao().ExpandRecord(
|
||||
record,
|
||||
expands,
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if expandErr != nil && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", expandErr)
|
||||
}
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
record,
|
||||
strings.Split(c.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
event := &core.RecordViewEvent{
|
||||
@@ -193,7 +191,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
testRecord := models.NewRecord(collection)
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
if err := testForm.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
@@ -210,7 +208,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := form.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordCreateEvent{
|
||||
@@ -218,20 +216,34 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
Record: record,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
// create the record
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
@@ -276,7 +288,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := form.LoadData(c.Request()); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordUpdateEvent{
|
||||
@@ -284,20 +296,34 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
Record: record,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
// update the record
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
@@ -348,13 +374,6 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
// try to delete the record files
|
||||
if err := api.deleteRecordFiles(e.Record); err != nil && api.app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen due to S3 api limits)
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
@@ -365,21 +384,6 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) deleteRecordFiles(record *models.Record) error {
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
failed := fs.DeletePrefix(record.BaseFilesPath())
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("Failed to delete %d record files.", len(failed))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
|
||||
result := map[string]any{}
|
||||
queryParams := map[string]any{}
|
||||
@@ -403,6 +407,24 @@ func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
|
||||
+114
-32
@@ -127,7 +127,7 @@ func TestRecordsList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "expand",
|
||||
Name: "expand relations",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created",
|
||||
RequestHeaders: map[string]string{
|
||||
@@ -139,17 +139,17 @@ func TestRecordsList(t *testing.T) {
|
||||
`"perPage":2`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"@expand":{`,
|
||||
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
|
||||
`"manyrels":[{`,
|
||||
`"manyrels":[]`,
|
||||
`"rel_cascade":"`,
|
||||
`"rel_cascade":null`,
|
||||
`"cascaderel":"`,
|
||||
`"onerel":{"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc","@collectionName":"demo",`,
|
||||
`"json":[1,2,3]`,
|
||||
`"select":["a","b"]`,
|
||||
`"select":[]`,
|
||||
`"user":null`,
|
||||
`"user":""`,
|
||||
`"bool":true`,
|
||||
`"number":456`,
|
||||
`"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`,
|
||||
@@ -357,6 +357,7 @@ func TestRecordView(t *testing.T) {
|
||||
`"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
|
||||
`"@collectionName":"demo2"`,
|
||||
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"@expand":{`,
|
||||
`"manyrels":[{`,
|
||||
`"onerel":{`,
|
||||
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
|
||||
@@ -455,11 +456,13 @@ func TestRecordDelete(t *testing.T) {
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnModelAfterUpdate": 1, // nullify related record
|
||||
"OnModelBeforeUpdate": 1, // nullify related record
|
||||
"OnModelBeforeDelete": 2, // +1 cascade delete related record
|
||||
"OnModelAfterDelete": 2, // +1 cascade delete related record
|
||||
"OnModelBeforeDelete": 3, // +2 cascade delete related records
|
||||
"OnModelAfterDelete": 3, // +2 cascade delete related records
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -475,11 +478,13 @@ func TestRecordDelete(t *testing.T) {
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnModelAfterUpdate": 1, // nullify related record
|
||||
"OnModelBeforeUpdate": 1, // nullify related record
|
||||
"OnModelBeforeDelete": 2, // +1 cascade delete related record
|
||||
"OnModelAfterDelete": 2, // +1 cascade delete related record
|
||||
"OnModelBeforeDelete": 3, // +2 cascade delete related records
|
||||
"OnModelAfterDelete": 3, // +2 cascade delete related records
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -554,8 +559,8 @@ func TestRecordDelete(t *testing.T) {
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeDelete": 2,
|
||||
"OnModelAfterDelete": 2,
|
||||
"OnModelBeforeDelete": 3,
|
||||
"OnModelAfterDelete": 3,
|
||||
},
|
||||
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
recId := "63c2ab80-84ab-4057-a592-4604a731f78f"
|
||||
@@ -565,6 +570,7 @@ func TestRecordDelete(t *testing.T) {
|
||||
t.Errorf("Expected record %s to be cascade deleted", recId)
|
||||
}
|
||||
ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9")
|
||||
ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f")
|
||||
},
|
||||
},
|
||||
@@ -646,11 +652,12 @@ func TestRecordCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{
|
||||
"rel_cascade": "577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"manyrels": ["577bd676-aacb-4072-b7da-99d00ee210a4"],
|
||||
"text": "test123",
|
||||
"bool": "false"
|
||||
"bool": "false",
|
||||
"number": 1
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
// test@example.com
|
||||
@@ -660,15 +667,17 @@ func TestRecordCreate(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "user submit in restricted collection (rule pass check)",
|
||||
Name: "user submit in restricted collection (rule pass check) + expand relations",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
|
||||
Body: strings.NewReader(`{
|
||||
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
|
||||
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
|
||||
"text":"test123",
|
||||
"bool":true
|
||||
"bool":true,
|
||||
"number":1
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
// test3@example.com
|
||||
@@ -677,11 +686,21 @@ func TestRecordCreate(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":`,
|
||||
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
|
||||
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"text":"test123"`,
|
||||
`"bool":true`,
|
||||
`"number":1`,
|
||||
`"@expand":{`,
|
||||
`"selfrel":{`,
|
||||
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// user don't have access to view the below expands
|
||||
`"manyrels":[{`,
|
||||
`"onerel":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
@@ -691,15 +710,17 @@ func TestRecordCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admin submit in restricted collection (rule skip check)",
|
||||
Name: "admin submit in restricted collection (rule skip check) + expand relations",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Url: "/api/collections/demo2/records?expand=missing,onerel,manyrels,selfrel",
|
||||
Body: strings.NewReader(`{
|
||||
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"cascaderel": "577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
|
||||
"text":"test123",
|
||||
"bool":false
|
||||
"selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9",
|
||||
"text": "test123",
|
||||
"bool": false,
|
||||
"number": 1
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
@@ -707,11 +728,20 @@ func TestRecordCreate(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":`,
|
||||
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
|
||||
`"text":"test123"`,
|
||||
`"bool":false`,
|
||||
`"number":1`,
|
||||
`"@expand":{`,
|
||||
`"manyrels":[{`,
|
||||
`"onerel":{`,
|
||||
`"selfrel":{`,
|
||||
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
|
||||
`"@collectionName":"demo"`,
|
||||
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
@@ -835,12 +865,13 @@ func TestRecordUpdate(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "user submit in restricted collection (rule pass check)",
|
||||
Name: "user submit in restricted collection (rule pass check) + expand relations",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
|
||||
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
|
||||
Body: strings.NewReader(`{
|
||||
"text":"test_new",
|
||||
"bool":false
|
||||
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
|
||||
"bool":true
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
// test3@example.com
|
||||
@@ -849,11 +880,19 @@ func TestRecordUpdate(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"cascaderel":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
|
||||
`"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`,
|
||||
`"bool":false`,
|
||||
`"bool":true`,
|
||||
`"text":"test_new"`,
|
||||
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"@expand":{`,
|
||||
`"selfrel":{`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// user don't have access to view the below expands
|
||||
`"manyrels":[{`,
|
||||
`"onerel":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
@@ -863,11 +902,44 @@ func TestRecordUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admin submit in restricted collection (rule skip check)",
|
||||
Name: "user submit in restricted collection (rule pass check) + expand relations (no view rule access when bool is false)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
|
||||
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=missing,onerel,manyrels,selfrel",
|
||||
Body: strings.NewReader(`{
|
||||
"text":"test_new"
|
||||
"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f",
|
||||
"bool":false
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
// test3@example.com
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"bool":false`,
|
||||
`"selfrel":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"@expand":{`,
|
||||
`"manyrels":[{`, // admin only
|
||||
`"onerel":{`, // admin only
|
||||
`"selfrel":{`, // bool=true view rule
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admin submit in restricted collection (rule skip check) + expand relations",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=onerel,manyrels,selfrel,missing",
|
||||
Body: strings.NewReader(`{
|
||||
"text":"test_new",
|
||||
"number":1,
|
||||
"selfrel":"94568ca2-0bee-49d7-b749-06cb97956fd9"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
@@ -876,6 +948,16 @@ func TestRecordUpdate(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
|
||||
`"text":"test_new"`,
|
||||
`"number":1`,
|
||||
`"@expand":{`,
|
||||
`"manyrels":[{`,
|
||||
`"onerel":{`,
|
||||
`"selfrel":{`,
|
||||
`"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
|
||||
`"@collectionName":"demo"`,
|
||||
`"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`,
|
||||
`"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`,
|
||||
`"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
|
||||
+19
-12
@@ -40,8 +40,10 @@ func (api *settingsApi) list(c echo.Context) error {
|
||||
|
||||
func (api *settingsApi) set(c echo.Context) error {
|
||||
form := forms.NewSettingsUpsert(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsUpdateEvent{
|
||||
@@ -50,22 +52,27 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
NewSettings: form.Settings,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while submitting the form.", err)
|
||||
}
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
}
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -139,9 +139,6 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
|
||||
`"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnSettingsBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin submitting valid data",
|
||||
|
||||
+37
-29
@@ -152,7 +152,7 @@ func (api *userApi) authMethods(c echo.Context) error {
|
||||
func (api *userApi) oauth2Auth(c echo.Context) error {
|
||||
form := forms.NewUserOauth2Login(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, authData, submitErr := form.Submit()
|
||||
@@ -170,7 +170,7 @@ func (api *userApi) emailAuth(c echo.Context) error {
|
||||
|
||||
form := forms.NewUserEmailLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
@@ -184,11 +184,11 @@ func (api *userApi) emailAuth(c echo.Context) error {
|
||||
func (api *userApi) requestPasswordReset(c echo.Context) error {
|
||||
form := forms.NewUserPasswordResetRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while validating the form.", err)
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
@@ -205,7 +205,7 @@ func (api *userApi) requestPasswordReset(c echo.Context) error {
|
||||
func (api *userApi) confirmPasswordReset(c echo.Context) error {
|
||||
form := forms.NewUserPasswordResetConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
@@ -224,7 +224,7 @@ func (api *userApi) requestEmailChange(c echo.Context) error {
|
||||
|
||||
form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
@@ -237,7 +237,7 @@ func (api *userApi) requestEmailChange(c echo.Context) error {
|
||||
func (api *userApi) confirmEmailChange(c echo.Context) error {
|
||||
form := forms.NewUserEmailChangeConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
@@ -251,11 +251,11 @@ func (api *userApi) confirmEmailChange(c echo.Context) error {
|
||||
func (api *userApi) requestVerification(c echo.Context) error {
|
||||
form := forms.NewUserVerificationRequest(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occured while validating the form.", err)
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
// run in background because we don't need to show
|
||||
@@ -272,12 +272,12 @@ func (api *userApi) requestVerification(c echo.Context) error {
|
||||
func (api *userApi) confirmVerification(c echo.Context) error {
|
||||
form := forms.NewUserVerificationConfirm(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
|
||||
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("An error occured while submitting the form.", submitErr)
|
||||
return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
|
||||
}
|
||||
|
||||
return api.authResponse(c, user, nil)
|
||||
@@ -348,7 +348,7 @@ func (api *userApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserCreateEvent{
|
||||
@@ -356,20 +356,24 @@ func (api *userApi) create(c echo.Context) error {
|
||||
User: user,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
|
||||
// create the user
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create user.", err)
|
||||
}
|
||||
// create the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *userApi) update(c echo.Context) error {
|
||||
@@ -387,7 +391,7 @@ func (api *userApi) update(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserUpdateEvent{
|
||||
@@ -395,20 +399,24 @@ func (api *userApi) update(c echo.Context) error {
|
||||
User: user,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
|
||||
// update the user
|
||||
if err := form.Submit(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update user.", err)
|
||||
}
|
||||
// update the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *userApi) delete(c echo.Context) error {
|
||||
|
||||
+58
-30
@@ -4,9 +4,12 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUsersAuthMethods(t *testing.T) {
|
||||
@@ -132,6 +135,7 @@ func TestUserRequestPasswordReset(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
@@ -139,22 +143,34 @@ func TestUserRequestPasswordReset(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
// usually this events are fired but since the submit is
|
||||
// executed in a separate go routine they are fired async
|
||||
// ExpectedEvents: map[string]int{
|
||||
// "OnModelBeforeUpdate": 1,
|
||||
// "OnModelAfterUpdate": 1,
|
||||
// "OnMailerBeforeUserResetPasswordSend": 1,
|
||||
// "OnMailerAfterUserResetPasswordSend": 1,
|
||||
// },
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeUserResetPasswordSend": 1,
|
||||
"OnMailerAfterUserResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing user (after already sent)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent password request
|
||||
user, err := app.Dao().FindUserByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.LastResetSentAt = types.NowDateTime()
|
||||
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
|
||||
if err := dao.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -216,50 +232,71 @@ func TestUserConfirmPasswordReset(t *testing.T) {
|
||||
|
||||
func TestUserRequestVerification(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
// empty data
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
},
|
||||
// invalid data
|
||||
{
|
||||
Name: "invalid data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// missing user
|
||||
{
|
||||
Name: "missing user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
// existing already verified user
|
||||
{
|
||||
Name: "existing already verified user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
// existing unverified user
|
||||
{
|
||||
Name: "existing unverified user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
// usually this events are fired but since the submit is
|
||||
// executed in a separate go routine they are fired async
|
||||
// ExpectedEvents: map[string]int{
|
||||
// "OnModelBeforeUpdate": 1,
|
||||
// "OnModelAfterUpdate": 1,
|
||||
// "OnMailerBeforeUserVerificationSend": 1,
|
||||
// "OnMailerAfterUserVerificationSend": 1,
|
||||
// },
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeUserVerificationSend": 1,
|
||||
"OnMailerAfterUserVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing unverified user (after already sent)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test2@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
// simulate recent verification sent
|
||||
user, err := app.Dao().FindUserByEmail("test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.LastVerificationSentAt = types.NowDateTime()
|
||||
dao := daos.New(app.Dao().DB()) // new dao to ignore hooks
|
||||
if err := dao.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -748,9 +785,6 @@ func TestUserCreate(t *testing.T) {
|
||||
`"email":{"code":"validation_required"`,
|
||||
`"password":{"code":"validation_required"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid data",
|
||||
@@ -764,9 +798,6 @@ func TestUserCreate(t *testing.T) {
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid data but with disabled email/pass auth",
|
||||
@@ -868,9 +899,6 @@ func TestUserUpdate(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_user_email_exists"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnUserBeforeUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin - valid data",
|
||||
|
||||
+10
-83
@@ -2,7 +2,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -10,14 +9,10 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -35,18 +30,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
Use: "serve",
|
||||
Short: "Starts the web server (default to localhost:8090)",
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
router, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
})))
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
panic(err)
|
||||
@@ -61,19 +44,18 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
|
||||
// if no admins are found, create the first one
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
router, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return
|
||||
}
|
||||
if totalAdmins == 0 {
|
||||
if err := promptCreateAdmin(app); err != nil {
|
||||
log.Fatalln(err)
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -149,7 +131,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
&httpsAddr,
|
||||
"https",
|
||||
"",
|
||||
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incomming --http address traffic also will be redirected to this address",
|
||||
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
|
||||
)
|
||||
|
||||
return command
|
||||
@@ -171,58 +153,3 @@ func runMigrations(app core.App) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptCreateAdmin(app core.App) error {
|
||||
color.White("-------------------------------------")
|
||||
color.Cyan("Lets create your first admin account:")
|
||||
color.White("-------------------------------------")
|
||||
|
||||
prompts := []*survey.Question{
|
||||
{
|
||||
Name: "Email",
|
||||
Prompt: &survey.Input{Message: "Email:"},
|
||||
Validate: func(val any) error {
|
||||
if err := survey.Required(val); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := is.Email.Validate(val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Password",
|
||||
Prompt: &survey.Password{Message: "Pass (min 10 chars):"},
|
||||
Validate: func(val any) error {
|
||||
if str, ok := val.(string); !ok || len(str) < 10 {
|
||||
return errors.New("The password must be at least 10 characters.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Email string
|
||||
Password string
|
||||
}{}
|
||||
if err := survey.Ask(prompts, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(app, &models.Admin{})
|
||||
form.Email = result.Email
|
||||
form.Password = result.Password
|
||||
form.PasswordConfirm = result.Password
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green("Successfully created admin %s!", result.Email)
|
||||
fmt.Println("")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Package cmd implements various PocketBase system commands.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewVersionCommand creates and returns new command that prints
|
||||
// the current PocketBase version.
|
||||
func NewVersionCommand(app core.App, version string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Prints the current PocketBase app version",
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
fmt.Printf("PocketBase v%s\n", version)
|
||||
},
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -91,7 +91,7 @@ type App interface {
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeCreate() *hook.Hook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfuly
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
OnModelAfterCreate() *hook.Hook[*ModelEvent]
|
||||
|
||||
@@ -99,7 +99,7 @@ type App interface {
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
OnModelBeforeUpdate() *hook.Hook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfuly updating
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
|
||||
|
||||
@@ -107,7 +107,7 @@ type App interface {
|
||||
// existing entry from the DB.
|
||||
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfuly deleting an
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelAfterDelete() *hook.Hook[*ModelEvent]
|
||||
|
||||
@@ -123,7 +123,7 @@ type App interface {
|
||||
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerAfterAdminResetPasswordSend hook is triggered after
|
||||
// admin password reset email was successfuly sent.
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
|
||||
@@ -134,7 +134,7 @@ type App interface {
|
||||
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// OnMailerAfterUserResetPasswordSend hook is triggered after
|
||||
// a user password reset email was successfuly sent.
|
||||
// a user password reset email was successfully sent.
|
||||
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// OnMailerBeforeUserVerificationSend hook is triggered right before
|
||||
@@ -145,7 +145,7 @@ type App interface {
|
||||
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// OnMailerAfterUserVerificationSend hook is triggered after a user
|
||||
// verification email was successfuly sent.
|
||||
// verification email was successfully sent.
|
||||
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
|
||||
@@ -156,7 +156,7 @@ type App interface {
|
||||
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
|
||||
// change address email was successfuly sent.
|
||||
// change address email was successfully sent.
|
||||
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -180,7 +180,7 @@ type App interface {
|
||||
// Settings API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnSettingsListRequest hook is triggered on each successfull
|
||||
// OnSettingsListRequest hook is triggered on each successful
|
||||
// API Settings list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before
|
||||
|
||||
+37
-1
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -123,7 +124,7 @@ type BaseApp struct {
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootsrap()`.
|
||||
func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
return &BaseApp{
|
||||
app := &BaseApp{
|
||||
dataDir: dataDir,
|
||||
isDebug: isDebug,
|
||||
encryptionEnv: encryptionEnv,
|
||||
@@ -209,6 +210,10 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
|
||||
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
|
||||
}
|
||||
|
||||
app.registerDefaultHooks()
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
@@ -342,6 +347,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
app.settings.S3.Endpoint,
|
||||
app.settings.S3.AccessKey,
|
||||
app.settings.S3.Secret,
|
||||
app.settings.S3.ForcePathStyle,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -750,3 +756,33 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
|
||||
|
||||
return dao
|
||||
}
|
||||
|
||||
func (app *BaseApp) registerDefaultHooks() {
|
||||
deletePrefix := func(prefix string) error {
|
||||
fs, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
failed := fs.DeletePrefix(prefix)
|
||||
if len(failed) > 0 {
|
||||
return errors.New("Failed to delete the files at " + prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete storage files from deleted Collection, Records, etc.
|
||||
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
|
||||
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
|
||||
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,8 +5,8 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
|
||||
+14
-13
@@ -148,12 +148,12 @@ func (s *Settings) Merge(other *Settings) error {
|
||||
}
|
||||
|
||||
// Clone creates a new deep copy of the current settings.
|
||||
func (c *Settings) Clone() (*Settings, error) {
|
||||
new := &Settings{}
|
||||
if err := new.Merge(c); err != nil {
|
||||
func (s *Settings) Clone() (*Settings, error) {
|
||||
settings := &Settings{}
|
||||
if err := settings.Merge(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return new, nil
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// RedactClone creates a new deep copy of the current settings,
|
||||
@@ -244,18 +244,19 @@ func (c SmtpConfig) Validate() error {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type S3Config struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
Bucket string `form:"bucket" json:"bucket"`
|
||||
Region string `form:"region" json:"region"`
|
||||
Endpoint string `form:"endpoint" json:"endpoint"`
|
||||
AccessKey string `form:"accessKey" json:"accessKey"`
|
||||
Secret string `form:"secret" json:"secret"`
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
Bucket string `form:"bucket" json:"bucket"`
|
||||
Region string `form:"region" json:"region"`
|
||||
Endpoint string `form:"endpoint" json:"endpoint"`
|
||||
AccessKey string `form:"accessKey" json:"accessKey"`
|
||||
Secret string `form:"secret" json:"secret"`
|
||||
ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
|
||||
}
|
||||
|
||||
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
|
||||
func (c S3Config) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(&c.Endpoint, is.Host, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
|
||||
validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
|
||||
@@ -360,11 +361,11 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
|
||||
}
|
||||
|
||||
if c.ClientId != "" {
|
||||
provider.SetClientId(string(c.ClientId))
|
||||
provider.SetClientId(c.ClientId)
|
||||
}
|
||||
|
||||
if c.ClientSecret != "" {
|
||||
provider.SetClientSecret(string(c.ClientSecret))
|
||||
provider.SetClientSecret(c.ClientSecret)
|
||||
}
|
||||
|
||||
if c.AuthUrl != "" {
|
||||
|
||||
+14
-2
@@ -172,7 +172,7 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******"},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
|
||||
@@ -315,7 +315,19 @@ func TestS3ConfigValidate(t *testing.T) {
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
// valid data (url endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
Enabled: true,
|
||||
Endpoint: "https://localhost:8090",
|
||||
Bucket: "test",
|
||||
Region: "test",
|
||||
AccessKey: "test",
|
||||
Secret: "test",
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (hostname endpoint)
|
||||
{
|
||||
core.S3Config{
|
||||
Enabled: true,
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindAdminByEmail finds the admin associated with the provided JWT token.
|
||||
// FindAdminByToken finds the admin associated with the provided JWT token.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
|
||||
+4
-7
@@ -115,9 +115,8 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
}
|
||||
}
|
||||
|
||||
deleteErr := dao.db.Model(m).Delete()
|
||||
if deleteErr != nil {
|
||||
return deleteErr
|
||||
if err := dao.db.Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
@@ -162,8 +161,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := dao.db.Model(m).Update()
|
||||
if err != nil {
|
||||
if err := dao.db.Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -203,8 +201,7 @@ func (dao *Dao) create(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := dao.db.Model(m).Insert()
|
||||
if err != nil {
|
||||
if err := dao.db.Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{"onerel", "manyrels", "rel_cascade"}
|
||||
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo2" {
|
||||
@@ -166,7 +166,6 @@ func TestDeleteCollection(t *testing.T) {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestSaveCollectionCreate(t *testing.T) {
|
||||
|
||||
+1
-3
@@ -40,7 +40,7 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
|
||||
param = &models.Param{Key: key}
|
||||
}
|
||||
|
||||
var normalizedValue any
|
||||
normalizedValue := value
|
||||
|
||||
// encrypt if optEncryptionKey is set
|
||||
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
|
||||
@@ -55,8 +55,6 @@ func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) err
|
||||
}
|
||||
|
||||
normalizedValue = encryptVal
|
||||
} else {
|
||||
normalizedValue = value
|
||||
}
|
||||
|
||||
encodedValue := types.JsonRaw{}
|
||||
|
||||
+33
-7
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -149,6 +150,10 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
// FindUserRelatedRecords returns all records that has a reference
|
||||
// to the provided User model (via the user shema field).
|
||||
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
|
||||
if user.Id == "" {
|
||||
return []*models.Record{}, nil
|
||||
}
|
||||
|
||||
collections, err := dao.FindCollectionsWithUserFields()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -309,7 +314,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
newSchema := newCollection.Schema
|
||||
|
||||
// check for renamed table
|
||||
if strings.ToLower(oldTableName) != strings.ToLower(newTableName) {
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -329,23 +334,44 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
if oldField != nil {
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, field.Name).Execute()
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, field.Name, field.ColDefinition()).Execute()
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+18
-14
@@ -17,21 +17,27 @@ const MaxExpandDepth = 6
|
||||
type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error)
|
||||
|
||||
// ExpandRecord expands the relations of a single Record model.
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ExpandRecords expands the relations of the provided Record models list.
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) error {
|
||||
//
|
||||
// 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 {
|
||||
normalized := normalizeExpands(expands)
|
||||
|
||||
failed := map[string]error{}
|
||||
|
||||
for _, expand := range normalized {
|
||||
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
|
||||
return err
|
||||
failed[expand] = err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return failed
|
||||
}
|
||||
|
||||
// notes:
|
||||
@@ -64,7 +70,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// extract the id of the relations to expand
|
||||
relIds := []string{}
|
||||
relIds := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
|
||||
}
|
||||
@@ -92,7 +98,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
for _, model := range records {
|
||||
relIds := model.GetStringSliceDataValue(relField.Name)
|
||||
|
||||
validRels := []*models.Record{}
|
||||
validRels := make([]*models.Record, 0, len(relIds))
|
||||
for _, id := range relIds {
|
||||
if rel, ok := indexedRels[id]; ok {
|
||||
validRels = append(validRels, rel)
|
||||
@@ -120,20 +126,18 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
// normalizeExpands normalizes expand strings and merges self containing paths
|
||||
// (eg. ["a.b.c", "a.b", " test ", " ", "test"] -> ["a.b.c", "test"]).
|
||||
func normalizeExpands(paths []string) []string {
|
||||
result := []string{}
|
||||
|
||||
// normalize paths
|
||||
normalized := []string{}
|
||||
normalized := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
p := strings.ReplaceAll(p, " ", "") // replace spaces
|
||||
p = strings.Trim(p, ".") // trim incomplete paths
|
||||
if p == "" {
|
||||
continue
|
||||
p = strings.ReplaceAll(p, " ", "") // replace spaces
|
||||
p = strings.Trim(p, ".") // trim incomplete paths
|
||||
if p != "" {
|
||||
normalized = append(normalized, p)
|
||||
}
|
||||
normalized = append(normalized, p)
|
||||
}
|
||||
|
||||
// merge containing paths
|
||||
result := make([]string, 0, len(normalized))
|
||||
for i, p1 := range normalized {
|
||||
var skip bool
|
||||
for j, p2 := range normalized {
|
||||
|
||||
+35
-37
@@ -19,11 +19,11 @@ func TestExpandRecords(t *testing.T) {
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
recordIds []string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectError bool
|
||||
recordIds []string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty records
|
||||
{
|
||||
@@ -33,7 +33,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// empty expand
|
||||
{
|
||||
@@ -43,7 +43,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
@@ -51,7 +51,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
nil,
|
||||
0,
|
||||
true,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
@@ -61,7 +61,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
true,
|
||||
2,
|
||||
},
|
||||
// invalid missing first level expand
|
||||
{
|
||||
@@ -71,7 +71,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
true,
|
||||
1,
|
||||
},
|
||||
// invalid missing second level expand
|
||||
{
|
||||
@@ -81,7 +81,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
true,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
@@ -96,7 +96,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
9,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
@@ -111,7 +111,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
2,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
@@ -121,18 +121,17 @@ func TestExpandRecords(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
6,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
ids := list.ToUniqueStringSlice(s.recordIds)
|
||||
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
|
||||
err := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
|
||||
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(records)
|
||||
@@ -140,7 +139,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,11 +151,11 @@ func TestExpandRecord(t *testing.T) {
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
recordId string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectError bool
|
||||
recordId string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty expand
|
||||
{
|
||||
@@ -166,7 +165,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
@@ -174,7 +173,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
nil,
|
||||
0,
|
||||
true,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
@@ -184,7 +183,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
true,
|
||||
2,
|
||||
},
|
||||
// invalid missing first level expand
|
||||
{
|
||||
@@ -194,7 +193,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
true,
|
||||
1,
|
||||
},
|
||||
// invalid missing second level expand
|
||||
{
|
||||
@@ -204,7 +203,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
0,
|
||||
true,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
@@ -214,7 +213,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
3,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
@@ -224,7 +223,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
1,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
@@ -234,17 +233,16 @@ func TestExpandRecord(t *testing.T) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
6,
|
||||
false,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
|
||||
err := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
|
||||
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(record)
|
||||
@@ -252,7 +250,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps)
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,7 @@ func TestFindUserRelatedRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(records) != len(scenario.expectedIds) {
|
||||
fmt.Println(records[0])
|
||||
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
|
||||
continue
|
||||
}
|
||||
@@ -396,6 +397,9 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
|
||||
+3
-3
@@ -43,10 +43,10 @@ func (dao *Dao) LoadProfiles(users []*models.User) error {
|
||||
}
|
||||
|
||||
// extract user ids
|
||||
ids := []string{}
|
||||
ids := make([]string, len(users))
|
||||
usersMap := map[string]*models.User{}
|
||||
for _, user := range users {
|
||||
ids = append(ids, user.Id)
|
||||
for i, user := range users {
|
||||
ids[i] = user.Id
|
||||
usersMap[user.Id] = user
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(s.token)
|
||||
tokenAdminId, _ := claims["id"]
|
||||
tokenAdminId := claims["id"]
|
||||
|
||||
if admin.Id != tokenAdminId {
|
||||
t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin)
|
||||
|
||||
@@ -74,8 +74,11 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
return validation.NewError("validation_admin_email_exists", "Admin email already exists.")
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form's admin model.
|
||||
func (form *AdminUpsert) Submit() error {
|
||||
// Submit validates the form and upserts the form admin model.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,5 +90,7 @@ func (form *AdminUpsert) Submit() error {
|
||||
form.admin.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
return form.app.Dao().SaveAdmin(form.admin)
|
||||
return runInterceptors(func() error {
|
||||
return form.app.Dao().SaveAdmin(form.admin)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -252,7 +253,14 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
})
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
@@ -266,6 +274,14 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
expectInterceptorCall := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue // skip persistence check
|
||||
}
|
||||
@@ -283,3 +299,51 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin := &models.Admin{}
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
form.Email = "test_new@example.com"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = form.Password
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorAdminEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorAdminEmail = admin.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor1, interceptor2)
|
||||
if err != testErr {
|
||||
t.Fatalf("Expected error %v, got %v", testErr, err)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorAdminEmail != form.Email {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package models implements various services used for request data
|
||||
// validation and applying changes to existing DB models through the app Dao.
|
||||
package forms
|
||||
|
||||
// InterceptorNextFunc is a interceptor handler function.
|
||||
// Usually used in combination with InterceptorFunc.
|
||||
type InterceptorNextFunc = func() error
|
||||
|
||||
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
|
||||
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
|
||||
|
||||
// runInterceptors executes the provided list of interceptors.
|
||||
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -78,7 +78,6 @@ func (form *CollectionUpsert) Validate() error {
|
||||
&form.Schema,
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureNoFieldsNameReuse),
|
||||
),
|
||||
validation.Field(&form.ListRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
|
||||
@@ -95,7 +94,7 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||
}
|
||||
|
||||
if (form.isCreate || strings.ToLower(v) != strings.ToLower(form.collection.Name)) && form.app.Dao().HasTable(v) {
|
||||
if (form.isCreate || !strings.EqualFold(v, form.collection.Name)) && form.app.Dao().HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
@@ -154,20 +153,6 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsNameReuse(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
for _, field := range v.Fields() {
|
||||
oldField := form.collection.Schema.GetFieldByName(field.Name)
|
||||
|
||||
if oldField != nil && oldField.Id != field.Id {
|
||||
return validation.NewError("validation_field_old_field_exist", "Cannot use existing schema field names when renaming fields.")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkRule(value any) error {
|
||||
v, _ := value.(*string)
|
||||
|
||||
@@ -189,7 +174,10 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
// Submit validates the form and upserts the form's Collection model.
|
||||
//
|
||||
// On success the related record table schema will be auto updated.
|
||||
func (form *CollectionUpsert) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -211,5 +199,7 @@ func (form *CollectionUpsert) Submit() error {
|
||||
form.collection.UpdateRule = form.UpdateRule
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
|
||||
return form.app.Dao().SaveCollection(form.collection)
|
||||
return runInterceptors(func() error {
|
||||
return form.app.Dao().SaveCollection(form.collection)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -260,7 +261,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update failure - rename fields to existing field names (aka. reusing field names)
|
||||
// update success - rename fields to existing field names (aka. reusing field names)
|
||||
{
|
||||
"test_new",
|
||||
`{
|
||||
@@ -269,7 +270,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
{"id":"b123456","name":"test1","type":"email"}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
[]string{},
|
||||
},
|
||||
// update failure - existing name
|
||||
{
|
||||
@@ -387,14 +388,31 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit()
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCall := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
@@ -450,3 +468,53 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
form.Name = "test_new"
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorCollectionName := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCollectionName = collection.Name // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorCollectionName != form.Name {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+99
-83
@@ -3,6 +3,7 @@ package forms
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -44,7 +45,6 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
form.Data = map[string]any{}
|
||||
for _, field := range record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = record.GetDataValue(field.Name)
|
||||
|
||||
}
|
||||
|
||||
return form
|
||||
@@ -147,79 +147,80 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
key := field.Name
|
||||
value, _ := extendedData[key]
|
||||
value := extendedData[key]
|
||||
value = field.PrepareValue(value)
|
||||
|
||||
if field.Type == schema.FieldTypeFile {
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
oldNames := list.ToUniqueStringSlice(form.Data[key])
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
form.Data[key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
// delete previously uploaded file(s)
|
||||
if options.MaxSelect == 1 {
|
||||
// search for unset zero indexed key as a fallback
|
||||
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
oldNames := list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = append(form.filesToDelete, oldNames...)
|
||||
}
|
||||
form.Data[key] = nil
|
||||
// delete previously uploaded file(s)
|
||||
if options.MaxSelect == 1 {
|
||||
// search for unset zero indexed key as a fallback
|
||||
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
|
||||
|
||||
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = append(form.filesToDelete, oldNames...)
|
||||
}
|
||||
} else if options.MaxSelect > 1 {
|
||||
// search for individual file index to delete (eg. "file.0")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
indexesToDelete := []int{}
|
||||
for indexedKey := range extendedData {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
indexesToDelete = append(indexesToDelete, index)
|
||||
}
|
||||
}
|
||||
|
||||
// slice to fill only with the non-deleted indexes
|
||||
nonDeleted := []string{}
|
||||
for i, name := range oldNames {
|
||||
// not marked for deletion
|
||||
if !list.ExistInSlice(i, indexesToDelete) {
|
||||
nonDeleted = append(nonDeleted, name)
|
||||
form.Data[key] = ""
|
||||
}
|
||||
} else if options.MaxSelect > 1 {
|
||||
// search for individual file index to delete (eg. "file.0")
|
||||
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
|
||||
indexesToDelete := make([]int, 0, len(extendedData))
|
||||
for indexedKey := range extendedData {
|
||||
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
|
||||
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
|
||||
if indexErr != nil || index >= len(oldNames) {
|
||||
continue
|
||||
}
|
||||
|
||||
// store the id to actually delete the file later
|
||||
form.filesToDelete = append(form.filesToDelete, name)
|
||||
indexesToDelete = append(indexesToDelete, index)
|
||||
}
|
||||
form.Data[key] = nonDeleted
|
||||
}
|
||||
|
||||
// check if there are any new uploaded form files
|
||||
files, err := rest.FindUploadedFiles(r, key)
|
||||
if err != nil {
|
||||
continue // skip invalid or missing file(s)
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
oldNames = list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// delete previous file(s) before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
// slice to fill only with the non-deleted indexes
|
||||
nonDeleted := make([]string, 0, len(oldNames))
|
||||
for i, name := range oldNames {
|
||||
// not marked for deletion
|
||||
if !list.ExistInSlice(i, indexesToDelete) {
|
||||
nonDeleted = append(nonDeleted, name)
|
||||
continue
|
||||
}
|
||||
form.filesToUpload = append(form.filesToUpload, files[0])
|
||||
form.Data[key] = files[0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
form.filesToUpload = append(form.filesToUpload, files...)
|
||||
for _, file := range files {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
|
||||
// store the id to actually delete the file later
|
||||
form.filesToDelete = append(form.filesToDelete, name)
|
||||
}
|
||||
} else {
|
||||
form.Data[key] = value
|
||||
form.Data[key] = nonDeleted
|
||||
}
|
||||
|
||||
// check if there are any new uploaded form files
|
||||
files, err := rest.FindUploadedFiles(r, key)
|
||||
if err != nil {
|
||||
continue // skip invalid or missing file(s)
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
oldNames = list.ToUniqueStringSlice(form.Data[key])
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
// delete previous file(s) before replacing
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
form.filesToUpload = append(form.filesToUpload, files[0])
|
||||
form.Data[key] = files[0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
form.filesToUpload = append(form.filesToUpload, files...)
|
||||
for _, file := range files {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +272,10 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form Record model.
|
||||
func (form *RecordUpsert) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -281,25 +285,27 @@ func (form *RecordUpsert) Submit() error {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// persist record model
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
return runInterceptors(func() error {
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// persist record model
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
if err := form.processFilesToDelete(); err != nil {
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
}
|
||||
// delete old files (if any)
|
||||
if err := form.processFilesToDelete(); err != nil { //nolint:staticcheck
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) processFilesToUpload() error {
|
||||
@@ -317,17 +323,22 @@ func (form *RecordUpsert) processFilesToUpload() error {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
var uploadErrors []error
|
||||
for i := len(form.filesToUpload) - 1; i >= 0; i-- {
|
||||
file := form.filesToUpload[i]
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
||||
|
||||
if err := fs.Upload(file.Bytes(), path); err == nil {
|
||||
// remove the uploaded file from the list
|
||||
form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
|
||||
} else {
|
||||
// store the upload error
|
||||
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(form.filesToUpload) > 0 {
|
||||
return errors.New("Failed to upload all files.")
|
||||
if len(uploadErrors) > 0 {
|
||||
return fmt.Errorf("Failed to upload all files: %v", uploadErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -348,20 +359,25 @@ func (form *RecordUpsert) processFilesToDelete() error {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
var deleteErrors []error
|
||||
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
|
||||
filename := form.filesToDelete[i]
|
||||
path := form.record.BaseFilesPath() + "/" + filename
|
||||
|
||||
if err := fs.Delete(path); err == nil {
|
||||
// remove the deleted file from the list
|
||||
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
|
||||
} else {
|
||||
// store the delete error
|
||||
deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
}
|
||||
|
||||
// try to delete the related file thumbs (if any)
|
||||
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
|
||||
}
|
||||
|
||||
if len(form.filesToDelete) > 0 {
|
||||
return errors.New("Failed to delete all files.")
|
||||
if len(deleteErrors) > 0 {
|
||||
return fmt.Errorf("Failed to delete all files: %v", deleteErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package forms_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -29,7 +30,7 @@ func TestNewRecordUpsert(t *testing.T) {
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
val, _ := form.Data["title"]
|
||||
val := form.Data["title"]
|
||||
if val != "test_value" {
|
||||
t.Errorf("Expected record data to be load, got %v", form.Data)
|
||||
}
|
||||
@@ -97,8 +98,8 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
}
|
||||
if onefile != nil {
|
||||
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
|
||||
if onefile != "" {
|
||||
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
@@ -165,8 +166,8 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
}
|
||||
if onefile != nil {
|
||||
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
|
||||
if onefile != "" {
|
||||
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
@@ -400,13 +401,27 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that validate is triggered
|
||||
// ---
|
||||
result := form.Submit()
|
||||
result := form.Submit(interceptor)
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
// ---
|
||||
if interceptorCalls != 0 {
|
||||
t.Fatalf("Expected interceptor to be called 0 times, got %d", interceptorCalls)
|
||||
}
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
@@ -451,11 +466,25 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
result := form.Submit(interceptor)
|
||||
if result != nil {
|
||||
t.Fatalf("Expected nil, got error %v", result)
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
// ---
|
||||
if interceptorCalls != 1 {
|
||||
t.Fatalf("Expected interceptor to be called 1 time, got %d", interceptorCalls)
|
||||
}
|
||||
|
||||
// ensure that the record changes were persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
@@ -482,6 +511,57 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.Data["title"] = "test_new"
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorRecordTitle := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorRecordTitle = record.GetStringDataValue("title") // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorRecordTitle != form.Data["title"].(string) {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
|
||||
fs, _ := app.NewFilesystem()
|
||||
defer fs.Close()
|
||||
|
||||
+20
-15
@@ -33,27 +33,32 @@ func (form *SettingsUpsert) Validate() error {
|
||||
// Submit validates the form and upserts the loaded settings.
|
||||
//
|
||||
// On success the app settings will be refreshed with the form ones.
|
||||
func (form *SettingsUpsert) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
|
||||
saveErr := form.app.Dao().SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return runInterceptors(func() error {
|
||||
saveErr := form.app.Dao().SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
// explicitly trigger old logs deletion
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
|
||||
// merge the application settings with the form ones
|
||||
return form.app.Settings().Merge(form.Settings)
|
||||
// merge the application settings with the form ones
|
||||
return form.app.Settings().Merge(form.Settings)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -98,14 +99,31 @@ func TestSettingsUpsertSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit()
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCall := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
@@ -128,3 +146,42 @@ func TestSettingsUpsertSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewSettingsUpsert(app)
|
||||
form.Meta.AppName = "test_new"
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserEmailChangeConfirm defines a user email change request form.
|
||||
// UserEmailChangeRequest defines a user email change request form.
|
||||
type UserEmailChangeRequest struct {
|
||||
app core.App
|
||||
user *models.User
|
||||
|
||||
+26
-25
@@ -68,7 +68,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
config, _ := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
config := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
config.SetupProvider(provider)
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
@@ -98,35 +98,36 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||
return nil, authData, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !config.AllowRegistrations {
|
||||
// registration of new users is not allowed via the Oauth2 provider
|
||||
return nil, authData, errors.New("Cannot find user with the authorized email.")
|
||||
}
|
||||
return user, authData, nil
|
||||
}
|
||||
|
||||
// create new user
|
||||
user = &models.User{Verified: true}
|
||||
upsertForm := NewUserUpsert(form.app, user)
|
||||
upsertForm.Email = authData.Email
|
||||
upsertForm.Password = security.RandomString(30)
|
||||
upsertForm.PasswordConfirm = upsertForm.Password
|
||||
if !config.AllowRegistrations {
|
||||
// registration of new users is not allowed via the Oauth2 provider
|
||||
return nil, authData, errors.New("Cannot find user with the authorized email.")
|
||||
}
|
||||
|
||||
event := &core.UserOauth2RegisterEvent{
|
||||
User: user,
|
||||
AuthData: authData,
|
||||
}
|
||||
// create new user
|
||||
user = &models.User{Verified: true}
|
||||
upsertForm := NewUserUpsert(form.app, user)
|
||||
upsertForm.Email = authData.Email
|
||||
upsertForm.Password = security.RandomString(30)
|
||||
upsertForm.PasswordConfirm = upsertForm.Password
|
||||
|
||||
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
event := &core.UserOauth2RegisterEvent{
|
||||
User: user,
|
||||
AuthData: authData,
|
||||
}
|
||||
|
||||
if err := upsertForm.Submit(); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
|
||||
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
if err := upsertForm.Submit(); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
|
||||
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
|
||||
return user, authData, nil
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestUserPasswordResetConfirmSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId, _ := claims["id"]
|
||||
tokenUserId := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user)
|
||||
|
||||
@@ -98,7 +98,10 @@ func (form *UserUpsert) checkEmailDomain(value any) error {
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form user model.
|
||||
func (form *UserUpsert) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -114,5 +117,7 @@ func (form *UserUpsert) Submit() error {
|
||||
|
||||
form.user.Email = form.Email
|
||||
|
||||
return form.app.Dao().SaveUser(form.user)
|
||||
return runInterceptors(func() error {
|
||||
return form.app.Dao().SaveUser(form.user)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -212,13 +213,28 @@ func TestUserUpsertSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
})
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
expectInterceptorCall := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
@@ -240,3 +256,51 @@ func TestUserUpsertSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user := &models.User{}
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
form.Email = "test_new@example.com"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = form.Password
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorUserEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorUserEmail = user.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor1, interceptor2)
|
||||
if err != testErr {
|
||||
t.Fatalf("Expected error %v, got %v", testErr, err)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorUserEmail != form.Email {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func TestUserVerificationConfirmSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId, _ := claims["id"]
|
||||
tokenUserId := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id)
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
@@ -240,7 +240,6 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
|
||||
|
||||
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
|
||||
val, _ := value.(types.DateTime)
|
||||
|
||||
if val.IsZero() {
|
||||
if field.Required {
|
||||
return requiredErr
|
||||
@@ -268,6 +267,9 @@ func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField,
|
||||
func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error {
|
||||
normalizedVal := list.ToUniqueStringSlice(value)
|
||||
if len(normalizedVal) == 0 {
|
||||
if field.Required {
|
||||
return requiredErr
|
||||
}
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
@@ -302,13 +304,9 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var names []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
names = v
|
||||
case string:
|
||||
names = []string{v}
|
||||
names := list.ToUniqueStringSlice(value)
|
||||
if len(names) == 0 && field.Required {
|
||||
return requiredErr
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.FileOptions)
|
||||
@@ -318,12 +316,10 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
// extract the uploaded files
|
||||
files := []*rest.UploadedFile{}
|
||||
if len(validator.uploadedFiles) > 0 {
|
||||
for _, file := range validator.uploadedFiles {
|
||||
if list.ExistInSlice(file.Name(), names) {
|
||||
files = append(files, file)
|
||||
}
|
||||
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles))
|
||||
for _, file := range validator.uploadedFiles {
|
||||
if list.ExistInSlice(file.Name(), names) {
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,16 +341,11 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var ids []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
ids = v
|
||||
case string:
|
||||
ids = []string{v}
|
||||
}
|
||||
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
if len(ids) == 0 {
|
||||
if field.Required {
|
||||
return requiredErr
|
||||
}
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
@@ -385,16 +376,11 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
|
||||
}
|
||||
|
||||
func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
|
||||
// normalize value access
|
||||
var ids []string
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
ids = v
|
||||
case string:
|
||||
ids = []string{v}
|
||||
}
|
||||
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
if len(ids) == 0 {
|
||||
if field.Required {
|
||||
return requiredErr
|
||||
}
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(text) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -99,7 +99,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(text) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
@@ -109,7 +109,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check min constraint",
|
||||
"(text) check min constraint",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
@@ -119,7 +119,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check max constraint",
|
||||
"(text) check max constraint",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
@@ -129,7 +129,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check pattern constraint",
|
||||
"(text) check pattern constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": "test",
|
||||
@@ -139,7 +139,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(text) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": "test",
|
||||
},
|
||||
@@ -147,7 +147,7 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(text) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": 12345, // test value cast
|
||||
@@ -168,7 +168,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
// create new test collection
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "validate_test"
|
||||
min := 1.0
|
||||
min := 0.0
|
||||
max := 150.0
|
||||
collection.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
@@ -205,7 +205,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(number) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -215,17 +215,17 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint + casting",
|
||||
"(number) check required constraint + casting",
|
||||
map[string]any{
|
||||
"field1": "invalid",
|
||||
"field2": "invalid",
|
||||
"field3": "invalid",
|
||||
},
|
||||
nil,
|
||||
[]string{"field2", "field3"},
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(number) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": 123,
|
||||
"field2": 123,
|
||||
@@ -235,7 +235,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check min constraint",
|
||||
"(number) check min constraint",
|
||||
map[string]any{
|
||||
"field1": 0.5,
|
||||
"field2": 1,
|
||||
@@ -245,7 +245,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check max constraint",
|
||||
"(number) check max constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": max,
|
||||
@@ -255,7 +255,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(number) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": 1,
|
||||
},
|
||||
@@ -263,7 +263,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(number) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": 123, // test value cast
|
||||
@@ -316,7 +316,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(bool) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -326,7 +326,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint + casting",
|
||||
"(bool) check required constraint + casting",
|
||||
map[string]any{
|
||||
"field1": "invalid",
|
||||
"field2": "invalid",
|
||||
@@ -336,7 +336,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(bool) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": true,
|
||||
"field2": true,
|
||||
@@ -346,7 +346,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(bool) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": 1,
|
||||
},
|
||||
@@ -354,7 +354,7 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(bool) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": false,
|
||||
"field2": true,
|
||||
@@ -412,7 +412,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(email) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -422,7 +422,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check email format validator",
|
||||
"(email) check email format validator",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test.com",
|
||||
@@ -432,7 +432,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{"field1", "field2", "field3"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(email) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "test@example.com",
|
||||
"field2": "test@test.com",
|
||||
@@ -442,7 +442,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check ExceptDomains constraint",
|
||||
"(email) check ExceptDomains constraint",
|
||||
map[string]any{
|
||||
"field1": "test@example.com",
|
||||
"field2": "test@example.com",
|
||||
@@ -452,7 +452,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check OnlyDomains constraint",
|
||||
"(email) check OnlyDomains constraint",
|
||||
map[string]any{
|
||||
"field1": "test@test.com",
|
||||
"field2": "test@test.com",
|
||||
@@ -462,7 +462,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(email) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": "test@test.com",
|
||||
},
|
||||
@@ -470,7 +470,7 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(email) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": "123@example.com",
|
||||
"field2": "test@test.com",
|
||||
@@ -528,7 +528,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(url) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -538,7 +538,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check url format validator",
|
||||
"(url) check url format validator",
|
||||
map[string]any{
|
||||
"field1": "/abc",
|
||||
"field2": "test.com", // valid
|
||||
@@ -548,7 +548,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field1", "field3"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(url) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "http://example.com",
|
||||
"field2": "http://test.com",
|
||||
@@ -558,7 +558,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check ExceptDomains constraint",
|
||||
"(url) check ExceptDomains constraint",
|
||||
map[string]any{
|
||||
"field1": "http://example.com",
|
||||
"field2": "http://example.com",
|
||||
@@ -568,7 +568,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check OnlyDomains constraint",
|
||||
"(url) check OnlyDomains constraint",
|
||||
map[string]any{
|
||||
"field1": "http://test.com/abc",
|
||||
"field2": "http://test.com/abc",
|
||||
@@ -578,7 +578,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check subdomains constraint",
|
||||
"(url) check subdomains constraint",
|
||||
map[string]any{
|
||||
"field1": "http://test.test.com",
|
||||
"field2": "http://test.example.com",
|
||||
@@ -588,7 +588,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(url) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": "http://sub.test.com/abc",
|
||||
},
|
||||
@@ -596,7 +596,7 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(url) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": "http://example.com/123",
|
||||
"field2": "http://test.com/",
|
||||
@@ -656,7 +656,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(date) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -666,7 +666,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint + cast",
|
||||
"(date) check required constraint + cast",
|
||||
map[string]any{
|
||||
"field1": "invalid",
|
||||
"field2": "invalid",
|
||||
@@ -676,7 +676,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint + zero datetime",
|
||||
"(date) check required constraint + zero datetime",
|
||||
map[string]any{
|
||||
"field1": "January 1, year 1, 00:00:00 UTC",
|
||||
"field2": "0001-01-01 00:00:00",
|
||||
@@ -686,7 +686,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(date) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "2029-01-01 01:01:01.123",
|
||||
"field2": "2029-01-01 01:01:01.123",
|
||||
@@ -696,7 +696,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check min date constraint",
|
||||
"(date) check min date constraint",
|
||||
map[string]any{
|
||||
"field1": "2021-01-01 01:01:01",
|
||||
"field2": "2021-01-01 01:01:01",
|
||||
@@ -706,7 +706,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check max date constraint",
|
||||
"(date) check max date constraint",
|
||||
map[string]any{
|
||||
"field1": "2030-02-01 01:01:01",
|
||||
"field2": "2030-02-01 01:01:01",
|
||||
@@ -716,7 +716,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"valid data (only required)",
|
||||
"(date) valid data (only required)",
|
||||
map[string]any{
|
||||
"field2": "2029-01-01 01:01:01",
|
||||
},
|
||||
@@ -724,7 +724,7 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data (all)",
|
||||
"(date) valid data (all)",
|
||||
map[string]any{
|
||||
"field1": "2029-01-01 01:01:01.000",
|
||||
"field2": "2029-01-01 01:01:01",
|
||||
@@ -788,7 +788,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint",
|
||||
"(select) check required constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -798,7 +798,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint - empty values",
|
||||
"(select) check required constraint - empty values",
|
||||
map[string]any{
|
||||
"field1": "",
|
||||
"field2": "",
|
||||
@@ -808,7 +808,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint - multiple select cast",
|
||||
"(select) check required constraint - multiple select cast",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": "a",
|
||||
@@ -818,7 +818,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(select) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": "b",
|
||||
@@ -828,7 +828,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check unique constraint - same elements but different order",
|
||||
"(select) check unique constraint - same elements but different order",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": "b",
|
||||
@@ -838,7 +838,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"check Values constraint",
|
||||
"(select) check Values constraint",
|
||||
map[string]any{
|
||||
"field1": 1,
|
||||
"field2": "d",
|
||||
@@ -848,7 +848,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{"field2", "field3"},
|
||||
},
|
||||
{
|
||||
"check MaxSelect constraint",
|
||||
"(select) check MaxSelect constraint",
|
||||
map[string]any{
|
||||
"field1": []string{"a", "b"}, // this will be normalized to a single string value
|
||||
"field2": []string{"a", "b", "c"},
|
||||
@@ -858,7 +858,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"valid data - only required fields",
|
||||
"(select) valid data - only required fields",
|
||||
map[string]any{
|
||||
"field2": []string{"a", "b"},
|
||||
},
|
||||
@@ -866,7 +866,7 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data - all fields with normalizations",
|
||||
"(select) valid data - all fields with normalizations",
|
||||
map[string]any{
|
||||
"field1": "a",
|
||||
"field2": []string{"a", "b", "b"}, // will be collapsed
|
||||
@@ -918,7 +918,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
|
||||
scenarios := []testDataFieldScenario{
|
||||
{
|
||||
"check required constraint - nil",
|
||||
"(json) check required constraint - nil",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
@@ -928,7 +928,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint - zero string",
|
||||
"(json) check required constraint - zero string",
|
||||
map[string]any{
|
||||
"field1": "",
|
||||
"field2": "",
|
||||
@@ -938,7 +938,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"check required constraint - zero number",
|
||||
"(json) check required constraint - zero number",
|
||||
map[string]any{
|
||||
"field1": 0,
|
||||
"field2": 0,
|
||||
@@ -948,7 +948,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"check required constraint - zero slice",
|
||||
"(json) check required constraint - zero slice",
|
||||
map[string]any{
|
||||
"field1": []string{},
|
||||
"field2": []string{},
|
||||
@@ -958,7 +958,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"check required constraint - zero map",
|
||||
"(json) check required constraint - zero map",
|
||||
map[string]any{
|
||||
"field1": map[string]string{},
|
||||
"field2": map[string]string{},
|
||||
@@ -968,7 +968,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"check unique constraint",
|
||||
"(json) check unique constraint",
|
||||
map[string]any{
|
||||
"field1": `{"test":123}`,
|
||||
"field2": `{"test":123}`,
|
||||
@@ -978,7 +978,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"check json text validator",
|
||||
"(json) check json text validator",
|
||||
map[string]any{
|
||||
"field1": `[1, 2, 3`,
|
||||
"field2": `invalid`,
|
||||
@@ -988,7 +988,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{"field1", "field2"},
|
||||
},
|
||||
{
|
||||
"valid data - only required fields",
|
||||
"(json) valid data - only required fields",
|
||||
map[string]any{
|
||||
"field2": `{"test":123}`,
|
||||
},
|
||||
@@ -996,7 +996,7 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"valid data - all fields with normalizations",
|
||||
"(json) valid data - all fields with normalizations",
|
||||
map[string]any{
|
||||
"field1": []string{"a", "b", "c"},
|
||||
"field2": 123,
|
||||
@@ -1189,7 +1189,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: 3,
|
||||
CollectionId: "", // missing or nonexisting colleciton id
|
||||
CollectionId: "", // missing or non-existing collection id
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
run:
|
||||
go: 1.18
|
||||
concurrency: 4
|
||||
timeout: 10m
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asciicheck
|
||||
- deadcode
|
||||
- depguard
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- gofmt
|
||||
- goimports
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- whitespace
|
||||
@@ -3,6 +3,8 @@ package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -20,7 +22,14 @@ func Register(
|
||||
down func(db dbx.Builder) error,
|
||||
optFilename ...string,
|
||||
) {
|
||||
AppMigrations.Register(up, down, optFilename...)
|
||||
var optFiles []string
|
||||
if len(optFilename) > 0 {
|
||||
optFiles = optFilename
|
||||
} else {
|
||||
_, path, _, _ := runtime.Caller(1)
|
||||
optFiles = append(optFiles, filepath.Base(path))
|
||||
}
|
||||
AppMigrations.Register(up, down, optFiles...)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
+8
-2
@@ -15,6 +15,12 @@ type ColumnValueMapper interface {
|
||||
ColumnValueMap() map[string]any
|
||||
}
|
||||
|
||||
// FilesManager defines an interface with common methods that files manager models should implement.
|
||||
type FilesManager interface {
|
||||
// BaseFilesPath returns the storage dir path used by the interface instance.
|
||||
BaseFilesPath() string
|
||||
}
|
||||
|
||||
// Model defines an interface with common methods that all db models should have.
|
||||
type Model interface {
|
||||
TableName() string
|
||||
@@ -53,7 +59,7 @@ func (m *BaseModel) GetCreated() types.DateTime {
|
||||
return m.Created
|
||||
}
|
||||
|
||||
// GetCreated returns the model's Updated datetime.
|
||||
// GetUpdated returns the model's Updated datetime.
|
||||
func (m *BaseModel) GetUpdated() types.DateTime {
|
||||
return m.Updated
|
||||
}
|
||||
@@ -71,7 +77,7 @@ func (m *BaseModel) RefreshCreated() {
|
||||
m.Created = types.NowDateTime()
|
||||
}
|
||||
|
||||
// RefreshCreated updates the model's Created field with the current datetime.
|
||||
// RefreshUpdated updates the model's Updated field with the current datetime.
|
||||
func (m *BaseModel) RefreshUpdated() {
|
||||
m.Updated = types.NowDateTime()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import "github.com/pocketbase/pocketbase/models/schema"
|
||||
|
||||
var _ Model = (*Collection)(nil)
|
||||
var _ FilesManager = (*Collection)(nil)
|
||||
|
||||
type Collection struct {
|
||||
BaseModel
|
||||
|
||||
+6
-5
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
var _ Model = (*Record)(nil)
|
||||
var _ ColumnValueMapper = (*Record)(nil)
|
||||
var _ FilesManager = (*Record)(nil)
|
||||
|
||||
type Record struct {
|
||||
BaseModel
|
||||
@@ -68,21 +69,21 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
|
||||
// NewRecordsFromNullStringMaps initializes a new Record model for
|
||||
// each row in the provided NullStringMap slice.
|
||||
func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
|
||||
result := []*Record{}
|
||||
result := make([]*Record, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
result = append(result, NewRecordFromNullStringMap(collection, row))
|
||||
for i, row := range rows {
|
||||
result[i] = NewRecordFromNullStringMap(collection, row)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Returns the table name associated to the current Record model.
|
||||
// TableName returns the table name associated to the current Record model.
|
||||
func (m *Record) TableName() string {
|
||||
return m.collection.Name
|
||||
}
|
||||
|
||||
// Returns the Collection model associated to the current Record model.
|
||||
// Collection returns the Collection model associated to the current Record model.
|
||||
func (m *Record) Collection() *Collection {
|
||||
return m.collection
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestNewRecordFromNullStringMap(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":null,"field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
|
||||
expected := `{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test","field2":"","field3":true,"field4":123.123,"field5":"test1","field6":["test"],"id":"c23eb053-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"}`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
@@ -185,10 +185,10 @@ func TestNewRecordsFromNullStringMaps(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":null,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
|
||||
expected := `[{"@collectionId":"","@collectionName":"test","created":"2022-01-01 10:00:00.123","field1":"test1","field2":0,"id":"11111111-d07e-4fbe-86b3-b8ac31982e9a","updated":"2022-01-01 10:00:00.456"},{"@collectionId":"","@collectionName":"test","created":"","field1":"test2","field2":123,"id":"22222222-d07e-4fbe-86b3-b8ac31982e9a","updated":""}]`
|
||||
|
||||
if string(encoded) != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, string(encoded))
|
||||
t.Fatalf("Expected \n%v, got \n%v", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ func (s *Schema) AddField(newField *SchemaField) {
|
||||
// checks for invalid renamed fields and field name duplications.
|
||||
func (s Schema) Validate() error {
|
||||
return validation.Validate(&s.fields, validation.Required, validation.By(func(value any) error {
|
||||
fields := s.fields // use directly the schema value to avoid unnecesary interface casting
|
||||
fields := s.fields // use directly the schema value to avoid unnecessary interface casting
|
||||
|
||||
if len(fields) == 0 {
|
||||
return validation.NewError("validation_invalid_schema", "Invalid schema format.")
|
||||
|
||||
@@ -218,31 +218,19 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
f.InitOptions()
|
||||
|
||||
switch f.Type {
|
||||
case FieldTypeText, FieldTypeEmail, FieldTypeUrl: // string
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
case FieldTypeText, FieldTypeEmail, FieldTypeUrl:
|
||||
return cast.ToString(value)
|
||||
case FieldTypeJson: // string
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
case FieldTypeJson:
|
||||
val, _ := types.ParseJsonRaw(value)
|
||||
return val
|
||||
case FieldTypeNumber: // nil, int or float
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
case FieldTypeNumber:
|
||||
return cast.ToFloat64(value)
|
||||
case FieldTypeBool: // bool
|
||||
case FieldTypeBool:
|
||||
return cast.ToBool(value)
|
||||
case FieldTypeDate: // string, DateTime or time.Time
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
case FieldTypeDate:
|
||||
val, _ := types.ParseDateTime(value)
|
||||
return val
|
||||
case FieldTypeSelect: // nil, string or slice of strings
|
||||
case FieldTypeSelect:
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*SelectOptions)
|
||||
@@ -250,11 +238,11 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
return val
|
||||
case FieldTypeFile: // nil, string or slice of strings
|
||||
case FieldTypeFile:
|
||||
val := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*FileOptions)
|
||||
@@ -262,11 +250,11 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
if len(val) > 0 {
|
||||
return val[0]
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
return val
|
||||
case FieldTypeRelation: // nil, string or slice of strings
|
||||
case FieldTypeRelation:
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*RelationOptions)
|
||||
@@ -274,11 +262,11 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
return ids
|
||||
case FieldTypeUser: // nil, string or slice of strings
|
||||
case FieldTypeUser:
|
||||
ids := list.ToUniqueStringSlice(value)
|
||||
|
||||
options, _ := f.Options.(*UserOptions)
|
||||
@@ -286,7 +274,7 @@ func (f *SchemaField) PrepareValue(value any) any {
|
||||
if len(ids) > 0 {
|
||||
return ids[0]
|
||||
}
|
||||
return nil
|
||||
return ""
|
||||
}
|
||||
|
||||
return ids
|
||||
|
||||
@@ -493,24 +493,27 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
value any
|
||||
expectJson string
|
||||
}{
|
||||
{schema.SchemaField{Type: "unkown"}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: "unkown"}, 123, "123"},
|
||||
{schema.SchemaField{Type: "unkown"}, []int{1, 2, 1}, "[1,2,1]"},
|
||||
{schema.SchemaField{Type: "unknown"}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: "unknown"}, 123, "123"},
|
||||
{schema.SchemaField{Type: "unknown"}, []int{1, 2, 1}, "[1,2,1]"},
|
||||
|
||||
// text
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, []int{1, 2}, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeText}, 123, `"123"`},
|
||||
|
||||
// email
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, []int{1, 2}, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeEmail}, 123, `"123"`},
|
||||
|
||||
// url
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, []int{1, 2}, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
|
||||
@@ -523,7 +526,9 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
|
||||
|
||||
// number
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "null"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, "", "0"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, "test", "0"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1, "1"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1.5, "1.5"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeNumber}, "1.5", "1.5"},
|
||||
@@ -533,22 +538,24 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, 1, "true"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, 0, "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, "", "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, "test", "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, "false", "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, "true", "true"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, false, "false"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeBool}, true, "true"},
|
||||
|
||||
// date
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, "null"},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
|
||||
|
||||
// select (single)
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
|
||||
@@ -624,8 +631,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
},
|
||||
|
||||
// file (single)
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
|
||||
@@ -690,8 +697,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
},
|
||||
|
||||
// relation (single)
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, "abc", `"abc"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeRelation}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
|
||||
@@ -761,8 +768,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
|
||||
},
|
||||
|
||||
// user (single)
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `null`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `""`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, 123, `"123"`},
|
||||
{schema.SchemaField{Type: schema.FieldTypeUser}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
|
||||
{
|
||||
@@ -1281,7 +1288,6 @@ func TestFileOptionsValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRelationOptionsValidate(t *testing.T) {
|
||||
|
||||
scenarios := []fieldOptionsScenario{
|
||||
{
|
||||
"empty",
|
||||
|
||||
+2
-2
@@ -9,10 +9,10 @@ import (
|
||||
var _ Model = (*User)(nil)
|
||||
|
||||
const (
|
||||
// The name of the system user profiles collection.
|
||||
// ProfileCollectionName is the name of the system user profiles collection.
|
||||
ProfileCollectionName = "profiles"
|
||||
|
||||
// The name of the user field from the system user profiles collection.
|
||||
// ProfileCollectionUserFieldName is the name of the user field from the system user profiles collection.
|
||||
ProfileCollectionUserFieldName = "userId"
|
||||
)
|
||||
|
||||
|
||||
+8
-7
@@ -17,7 +17,7 @@ import (
|
||||
var _ core.App = (*PocketBase)(nil)
|
||||
|
||||
// Version of PocketBase
|
||||
const Version = "0.1.0"
|
||||
var Version = "(untracked)"
|
||||
|
||||
// appWrapper serves as a private core.App instance wrapper.
|
||||
type appWrapper struct {
|
||||
@@ -73,11 +73,16 @@ func New() *PocketBase {
|
||||
|
||||
pb := &PocketBase{
|
||||
RootCmd: &cobra.Command{
|
||||
Use: "pocketbase",
|
||||
Short: "PocketBase CLI",
|
||||
Use: "pocketbase",
|
||||
Short: "PocketBase CLI",
|
||||
Version: Version,
|
||||
FParseErrWhitelist: cobra.FParseErrWhitelist{
|
||||
UnknownFlags: true,
|
||||
},
|
||||
// no need to provide the default cobra completion command
|
||||
CompletionOptions: cobra.CompletionOptions{
|
||||
DisableDefaultCmd: true,
|
||||
},
|
||||
},
|
||||
defaultDebug: withGoRun,
|
||||
defaultDataDir: defaultDir,
|
||||
@@ -85,9 +90,6 @@ func New() *PocketBase {
|
||||
showStartBanner: true,
|
||||
}
|
||||
|
||||
// no need to provide the default cobra completion command
|
||||
pb.RootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
// parse base flags
|
||||
// (errors are ignored, since the full flags parsing happens on Execute())
|
||||
pb.eagerParseFlags()
|
||||
@@ -130,7 +132,6 @@ func (pb *PocketBase) ShowStartBanner(val bool) *PocketBase {
|
||||
func (pb *PocketBase) Start() error {
|
||||
// register system commands
|
||||
pb.RootCmd.AddCommand(cmd.NewServeCommand(pb, pb.showStartBanner))
|
||||
pb.RootCmd.AddCommand(cmd.NewVersionCommand(pb, Version))
|
||||
pb.RootCmd.AddCommand(cmd.NewMigrateCommand(pb))
|
||||
|
||||
return pb.Execute()
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
var _ search.FieldResolver = (*RecordFieldResolver)(nil)
|
||||
|
||||
type join struct {
|
||||
id string
|
||||
table string
|
||||
on dbx.Expression
|
||||
}
|
||||
@@ -36,7 +37,7 @@ type RecordFieldResolver struct {
|
||||
baseCollection *models.Collection
|
||||
allowedFields []string
|
||||
requestData map[string]any
|
||||
joins map[string]join
|
||||
joins []join // we cannot use a map because the insertion order is not preserved
|
||||
loadedCollections []*models.Collection
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ func NewRecordFieldResolver(
|
||||
dao: dao,
|
||||
baseCollection: baseCollection,
|
||||
requestData: requestData,
|
||||
joins: make(map[string]join),
|
||||
joins: []join{},
|
||||
loadedCollections: []*models.Collection{baseCollection},
|
||||
allowedFields: []string{
|
||||
`^\w+[\w\.]*$`,
|
||||
@@ -85,6 +86,7 @@ func (r *RecordFieldResolver) UpdateQuery(query *dbx.SelectQuery) error {
|
||||
// id
|
||||
// project.screen.status
|
||||
// @request.status
|
||||
// @request.user.profile.someRelation.name
|
||||
// @collection.product.name
|
||||
func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, placeholderParams dbx.Params, err error) {
|
||||
if len(r.allowedFields) > 0 && !list.ExistInSliceWithRegex(fieldName, r.allowedFields) {
|
||||
@@ -93,15 +95,6 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||
|
||||
props := strings.Split(fieldName, ".")
|
||||
|
||||
// check for @request field
|
||||
if props[0] == "@request" {
|
||||
if len(props) == 1 {
|
||||
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
|
||||
}
|
||||
|
||||
return r.resolveRequestField(props[1:]...)
|
||||
}
|
||||
|
||||
currentCollectionName := r.baseCollection.Name
|
||||
currentTableAlias := currentCollectionName
|
||||
|
||||
@@ -113,16 +106,54 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||
}
|
||||
|
||||
currentCollectionName = props[1]
|
||||
currentTableAlias = "c_" + currentCollectionName
|
||||
currentTableAlias = "__collection_" + currentCollectionName
|
||||
|
||||
collection, err := r.loadCollection(currentCollectionName)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
||||
}
|
||||
|
||||
r.addJoin(collection.Name, currentTableAlias, "", "", "")
|
||||
r.addJoin(collection.Name, currentTableAlias, nil)
|
||||
|
||||
props = props[2:] // leave only the collection fields
|
||||
} else if props[0] == "@request" {
|
||||
// check for @request field
|
||||
if len(props) == 1 {
|
||||
return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
|
||||
}
|
||||
|
||||
// not a profile relational field
|
||||
if len(props) <= 4 || !strings.HasPrefix(fieldName, "@request.user.profile.") {
|
||||
return r.resolveStaticRequestField(props[1:]...)
|
||||
}
|
||||
|
||||
// resolve the profile collection fields
|
||||
currentCollectionName = models.ProfileCollectionName
|
||||
currentTableAlias = "__user_" + currentCollectionName
|
||||
|
||||
collection, err := r.loadCollection(currentCollectionName)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName)
|
||||
}
|
||||
|
||||
profileIdPlaceholder, profileIdPlaceholderParam, err := r.resolveStaticRequestField("user", "profile", "id")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("Failed to resolve @request.user.profile.id path in %q.", fieldName)
|
||||
}
|
||||
if strings.ToLower(profileIdPlaceholder) == "null" {
|
||||
// the user doesn't have an associated profile
|
||||
return "NULL", nil, nil
|
||||
}
|
||||
|
||||
// join the profile collection
|
||||
r.addJoin(collection.Name, currentTableAlias, dbx.NewExp(fmt.Sprintf(
|
||||
// aka. profiles.id = profileId
|
||||
"[[%s.id]] = %s",
|
||||
inflector.Columnify(currentTableAlias),
|
||||
profileIdPlaceholder,
|
||||
), profileIdPlaceholderParam))
|
||||
|
||||
props = props[3:] // leave only the profile fields
|
||||
}
|
||||
|
||||
baseModelFields := schema.ReservedFieldNames()
|
||||
@@ -168,14 +199,19 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||
return "", nil, fmt.Errorf("Failed to find field %q collection.", prop)
|
||||
}
|
||||
newCollectionName := relCollection.Name
|
||||
newTableAlias := (currentTableAlias + "_" + field.Name)
|
||||
newTableAlias := currentTableAlias + "_" + field.Name
|
||||
|
||||
r.addJoin(
|
||||
newCollectionName,
|
||||
newTableAlias,
|
||||
"id",
|
||||
currentTableAlias,
|
||||
field.Name,
|
||||
dbx.NewExp(fmt.Sprintf(
|
||||
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
|
||||
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
|
||||
inflector.Columnify(currentTableAlias),
|
||||
inflector.Columnify(field.Name),
|
||||
inflector.Columnify(newTableAlias),
|
||||
inflector.Columnify("id"),
|
||||
)),
|
||||
)
|
||||
|
||||
currentCollectionName = newCollectionName
|
||||
@@ -185,7 +221,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
|
||||
return "", nil, fmt.Errorf("Failed to resolve field %q.", fieldName)
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) resolveRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
|
||||
func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
|
||||
// ignore error because requestData is dynamic and some of the
|
||||
// lookup keys may not be defined for the request
|
||||
resultVal, _ := extractNestedMapVal(r.requestData, path...)
|
||||
@@ -259,24 +295,27 @@ func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func (r *RecordFieldResolver) addJoin(tableName, tableAlias, fieldName, ref, refFieldName string) {
|
||||
table := fmt.Sprintf(
|
||||
func (r *RecordFieldResolver) addJoin(tableName string, tableAlias string, on dbx.Expression) {
|
||||
tableExpr := fmt.Sprintf(
|
||||
"%s %s",
|
||||
inflector.Columnify(tableName),
|
||||
inflector.Columnify(tableAlias),
|
||||
)
|
||||
|
||||
var on dbx.Expression
|
||||
if ref != "" {
|
||||
on = dbx.NewExp(fmt.Sprintf(
|
||||
// 'LIKE' expr is used to handle the case when the reference field supports multiple values (aka. is json array)
|
||||
"[[%s.%s]] LIKE ('%%' || [[%s.%s]] || '%%')",
|
||||
inflector.Columnify(ref),
|
||||
inflector.Columnify(refFieldName),
|
||||
inflector.Columnify(tableAlias),
|
||||
inflector.Columnify(fieldName),
|
||||
))
|
||||
join := join{
|
||||
id: tableAlias,
|
||||
table: tableExpr,
|
||||
on: on,
|
||||
}
|
||||
|
||||
r.joins[tableAlias] = join{table, on}
|
||||
// replace existing join
|
||||
for i, j := range r.joins {
|
||||
if j.id == join.id {
|
||||
r.joins[i] = join
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// register new join
|
||||
r.joins = append(r.joins, join)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package resolvers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
@@ -18,54 +19,94 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestData := map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
"profile": map[string]any{
|
||||
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||
"name": "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
fieldName string
|
||||
expectQueryParts []string // we are matching parts of the query
|
||||
// since joins are added with map iteration and the order is not guaranteed
|
||||
name string
|
||||
fields []string
|
||||
expectQuery string
|
||||
}{
|
||||
// missing field
|
||||
{"", []string{
|
||||
{
|
||||
"missing field",
|
||||
[]string{""},
|
||||
"SELECT `demo4`.* FROM `demo4`",
|
||||
}},
|
||||
// non relation field
|
||||
{"title", []string{
|
||||
},
|
||||
{
|
||||
"non relation field",
|
||||
[]string{"title"},
|
||||
"SELECT `demo4`.* FROM `demo4`",
|
||||
}},
|
||||
// incomplete rel
|
||||
{"onerel", []string{
|
||||
},
|
||||
{
|
||||
"incomplete rel",
|
||||
[]string{"onerel"},
|
||||
"SELECT `demo4`.* FROM `demo4`",
|
||||
}},
|
||||
// single rel
|
||||
{"onerel.title", []string{
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
||||
" LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||
}},
|
||||
// nested incomplete rels
|
||||
{"manyrels.onerel", []string{
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
||||
}},
|
||||
// nested complete rels
|
||||
{"manyrels.onerel.title", []string{
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
||||
}},
|
||||
// // repeated nested rels
|
||||
{"manyrels.onerel.manyrels.onerel.title", []string{
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4`",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%')",
|
||||
" LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
|
||||
}},
|
||||
},
|
||||
{
|
||||
"single rel",
|
||||
[]string{"onerel.title"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"non-relation field + single rel",
|
||||
[]string{"title", "onerel.title"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"nested incomplete rels",
|
||||
[]string{"manyrels.onerel"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"nested complete rels",
|
||||
[]string{"manyrels.onerel.title"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"repeated nested rels",
|
||||
[]string{"manyrels.onerel.manyrels.onerel.title"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel` ON [[demo4_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels` ON [[demo4_manyrels_onerel.manyrels]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_manyrels_onerel_manyrels_onerel` ON [[demo4_manyrels_onerel_manyrels.onerel]] LIKE ('%' || [[demo4_manyrels_onerel_manyrels_onerel.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"multiple rels",
|
||||
[]string{"manyrels.title", "onerel.onefile"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo4` `demo4_manyrels` ON [[demo4.manyrels]] LIKE ('%' || [[demo4_manyrels.id]] || '%') LEFT JOIN `demo4` `demo4_onerel` ON [[demo4.onerel]] LIKE ('%' || [[demo4_onerel.id]] || '%')",
|
||||
},
|
||||
{
|
||||
"@collection join",
|
||||
[]string{"@collection.demo.title", "@collection.demo2.text", "@collection.demo.file"},
|
||||
"SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `demo` `__collection_demo` LEFT JOIN `demo2` `__collection_demo2`",
|
||||
},
|
||||
{
|
||||
"static @request.user.profile fields",
|
||||
[]string{"@request.user.id", "@request.user.profile.id", "@request.data.demo"},
|
||||
"SELECT `demo4`.* FROM `demo4`",
|
||||
},
|
||||
{
|
||||
"relational @request.user.profile fields",
|
||||
[]string{"@request.user.profile.rel.id", "@request.user.profile.rel.name"},
|
||||
"^" +
|
||||
regexp.QuoteMeta("SELECT DISTINCT `demo4`.* FROM `demo4` LEFT JOIN `profiles` `__user_profiles` ON [[__user_profiles.id]] =") +
|
||||
" {:.*} " +
|
||||
regexp.QuoteMeta("LEFT JOIN `profiles` `__user_profiles_rel` ON [[__user_profiles.rel]] LIKE ('%' || [[__user_profiles_rel.id]] || '%')") +
|
||||
"$",
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
query := app.Dao().RecordQuery(collection)
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
|
||||
r.Resolve(s.fieldName)
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||
for _, field := range s.fields {
|
||||
r.Resolve(field)
|
||||
}
|
||||
|
||||
if err := r.UpdateQuery(query); err != nil {
|
||||
t.Errorf("(%d) UpdateQuery failed with error %v", i, err)
|
||||
@@ -74,16 +115,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
|
||||
|
||||
rawQuery := query.Build().SQL()
|
||||
|
||||
partsLength := 0
|
||||
for _, part := range s.expectQueryParts {
|
||||
partsLength += len(part)
|
||||
if !strings.Contains(rawQuery, part) {
|
||||
t.Errorf("(%d) Part %v is missing from query \n%v", i, part, rawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
if partsLength != len(rawQuery) {
|
||||
t.Errorf("(%d) Expected %d characters, got %d in \n%v", i, partsLength, len(rawQuery), rawQuery)
|
||||
if !list.ExistInSliceWithRegex(rawQuery, []string{s.expectQuery}) {
|
||||
t.Errorf("(%d) Expected query\n %v \ngot:\n %v", i, s.expectQuery, rawQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +130,16 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, nil)
|
||||
requestData := map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
"profile": map[string]any{
|
||||
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||
|
||||
scenarios := []struct {
|
||||
fieldName string
|
||||
@@ -118,42 +160,45 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
|
||||
{"manyrels.unknown", true, ""},
|
||||
{"manyrels.title", false, "[[demo4_manyrels.title]]"},
|
||||
{"manyrels.onerel.manyrels.onefile", false, "[[demo4_manyrels_onerel_manyrels.onefile]]"},
|
||||
// @request.user.profile relation join:
|
||||
{"@request.user.profile.rel.name", false, "[[__user_profiles_rel.name]]"},
|
||||
// @collection fieds:
|
||||
{"@collect", true, ""},
|
||||
{"collection.demo4.title", true, ""},
|
||||
{"@collection", true, ""},
|
||||
{"@collection.unknown", true, ""},
|
||||
{"@collection.demo", true, ""},
|
||||
{"@collection.demo.", true, ""},
|
||||
{"@collection.demo.title", false, "[[c_demo.title]]"},
|
||||
{"@collection.demo4.title", false, "[[c_demo4.title]]"},
|
||||
{"@collection.demo4.id", false, "[[c_demo4.id]]"},
|
||||
{"@collection.demo4.created", false, "[[c_demo4.created]]"},
|
||||
{"@collection.demo4.updated", false, "[[c_demo4.updated]]"},
|
||||
{"@collection.demo.title", false, "[[__collection_demo.title]]"},
|
||||
{"@collection.demo4.title", false, "[[__collection_demo4.title]]"},
|
||||
{"@collection.demo4.id", false, "[[__collection_demo4.id]]"},
|
||||
{"@collection.demo4.created", false, "[[__collection_demo4.created]]"},
|
||||
{"@collection.demo4.updated", false, "[[__collection_demo4.updated]]"},
|
||||
{"@collection.demo4.manyrels.missing", true, ""},
|
||||
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[c_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
|
||||
{"@collection.demo4.manyrels.onerel.manyrels.onerel.onefile", false, "[[__collection_demo4_manyrels_onerel_manyrels_onerel.onefile]]"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
name, params, err := r.Resolve(s.fieldName)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
t.Errorf("(%q) Expected hasErr %v, got %v (%v)", s.fieldName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if name != s.expectName {
|
||||
t.Errorf("(%d) Expected name %q, got %q", i, s.expectName, name)
|
||||
t.Errorf("(%q) Expected name %q, got %q", s.fieldName, s.expectName, name)
|
||||
}
|
||||
|
||||
// params should be empty for non @request fields
|
||||
if len(params) != 0 {
|
||||
t.Errorf("(%d) Expected 0 params, got %v", i, params)
|
||||
t.Errorf("(%q) Expected 0 params, got %v", s.fieldName, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
||||
func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -171,7 +216,13 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
||||
"b": 456,
|
||||
"c": map[string]int{"sub": 1},
|
||||
},
|
||||
"user": nil,
|
||||
"user": map[string]any{
|
||||
"id": "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
"profile": map[string]any{
|
||||
"id": "d13f60a4-5765-48c7-9e1d-3e782340f833",
|
||||
"name": "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData)
|
||||
@@ -194,7 +245,9 @@ func TestRecordFieldResolverResolveRequestDataFields(t *testing.T) {
|
||||
{"@request.data.b.missing", false, ``},
|
||||
{"@request.data.c", false, `"{\"sub\":1}"`},
|
||||
{"@request.user", true, ""},
|
||||
{"@request.user.id", false, ""},
|
||||
{"@request.user.id", false, `"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`},
|
||||
{"@request.user.profile", false, `"{\"id\":\"d13f60a4-5765-48c7-9e1d-3e782340f833\",\"name\":\"test\"}"`},
|
||||
{"@request.user.profile.name", false, `"test"`},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
|
||||
+29
-9
@@ -22,11 +22,20 @@ type ApiScenario struct {
|
||||
Url string
|
||||
Body io.Reader
|
||||
RequestHeaders map[string]string
|
||||
|
||||
// Delay adds a delay before checking the expectations usually
|
||||
// to ensure that all fired non-awaited go routines have finished
|
||||
Delay time.Duration
|
||||
|
||||
// expectations
|
||||
ExpectedStatus int
|
||||
ExpectedContent []string
|
||||
ExpectedEvents map[string]int
|
||||
// test events
|
||||
// ---
|
||||
ExpectedStatus int
|
||||
ExpectedContent []string
|
||||
NotExpectedContent []string
|
||||
ExpectedEvents map[string]int
|
||||
|
||||
// test hooks
|
||||
// ---
|
||||
BeforeFunc func(t *testing.T, app *TestApp, e *echo.Echo)
|
||||
AfterFunc func(t *testing.T, app *TestApp, e *echo.Echo)
|
||||
}
|
||||
@@ -48,7 +57,7 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(scenario.Method, scenario.Url, scenario.Body)
|
||||
|
||||
// add middeware to timeout long running requests (eg. keep-alive routes)
|
||||
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
||||
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx, cancelFunc := context.WithTimeout(c.Request().Context(), 100*time.Millisecond)
|
||||
@@ -80,9 +89,13 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
t.Errorf("[%s] Expected status code %d, got %d", prefix, scenario.ExpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
if len(scenario.ExpectedContent) == 0 {
|
||||
if scenario.Delay > 0 {
|
||||
time.Sleep(scenario.Delay)
|
||||
}
|
||||
|
||||
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||
if len(recorder.Body.Bytes()) != 0 {
|
||||
t.Errorf("[%s] Expected empty body, got %v", prefix, recorder.Body.String())
|
||||
t.Errorf("[%s] Expected empty body, got \n%v", prefix, recorder.Body.String())
|
||||
}
|
||||
} else {
|
||||
// normalize json response format
|
||||
@@ -98,7 +111,14 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
|
||||
for _, item := range scenario.ExpectedContent {
|
||||
if !strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("[%s] Cannot find %v in response body %v", prefix, item, normalizedBody)
|
||||
t.Errorf("[%s] Cannot find %v in response body \n%v", prefix, item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range scenario.NotExpectedContent {
|
||||
if strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("[%s] Didn't expect %v in response body \n%v", prefix, item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -109,7 +129,7 @@ func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
}
|
||||
|
||||
for event, expectedCalls := range scenario.ExpectedEvents {
|
||||
actualCalls, _ := testApp.EventCalls[event]
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Errorf("[%s] Expected event %s to be called %d, got %d", prefix, event, expectedCalls, actualCalls)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Pacakge tests provides common helpers and mocks used in PocketBase application tests.
|
||||
// Package tests provides common helpers and mocks used in PocketBase application tests.
|
||||
package tests
|
||||
|
||||
import (
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -29,7 +29,7 @@ type Provider interface {
|
||||
// SetClientId sets the provider client's ID.
|
||||
SetClientId(clientId string)
|
||||
|
||||
// ClientId returns the provider client's app secret.
|
||||
// ClientSecret returns the provider client's app secret.
|
||||
ClientSecret() string
|
||||
|
||||
// SetClientSecret sets the provider client's app secret.
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
@@ -120,6 +121,16 @@ func (p *baseProvider) FetchRawUserData(token *oauth2.Token, result any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// http.Client.Get doesn't treat non 2xx responses as error
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf(
|
||||
"Failed to fetch OAuth2 user profile via %s (%d):\n%s",
|
||||
p.userApiUrl,
|
||||
response.StatusCode,
|
||||
string(content),
|
||||
)
|
||||
}
|
||||
|
||||
return json.Unmarshal(content, &result)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"gocloud.dev/blob"
|
||||
"gocloud.dev/blob/fileblob"
|
||||
"gocloud.dev/blob/s3blob"
|
||||
@@ -35,15 +36,17 @@ func NewS3(
|
||||
endpoint string,
|
||||
accessKey string,
|
||||
secretKey string,
|
||||
s3ForcePathStyle bool,
|
||||
) (*System, error) {
|
||||
ctx := context.Background() // default context
|
||||
|
||||
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
||||
|
||||
sess, err := session.NewSession(&aws.Config{
|
||||
Region: aws.String(region),
|
||||
Endpoint: aws.String(endpoint),
|
||||
Credentials: cred,
|
||||
Region: aws.String(region),
|
||||
Endpoint: aws.String(endpoint),
|
||||
Credentials: cred,
|
||||
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -127,7 +130,7 @@ func (s *System) DeletePrefix(prefix string) []error {
|
||||
Prefix: prefix,
|
||||
}
|
||||
|
||||
// delete all files witht the prefix
|
||||
// delete all files with the prefix
|
||||
// ---
|
||||
iter := s.bucket.List(&opts)
|
||||
for {
|
||||
@@ -153,7 +156,7 @@ func (s *System) DeletePrefix(prefix string) []error {
|
||||
// (this operation usually is optional and there is no need to strictly check the result)
|
||||
// ---
|
||||
// fill dirs slice
|
||||
dirs := []string{}
|
||||
dirs := make([]string, 0, len(dirsMap))
|
||||
for d := range dirsMap {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
@@ -174,6 +177,11 @@ func (s *System) DeletePrefix(prefix string) []error {
|
||||
return failed
|
||||
}
|
||||
|
||||
var inlineServeContentTypes = []string{
|
||||
"image/png", "image/jpg", "image/jpeg", "image/gif",
|
||||
"video/mp4", "video/3gpp", "video/quicktime", " video/x-ms-wmv",
|
||||
}
|
||||
|
||||
// Serve serves the file at fileKey location to an HTTP response.
|
||||
func (s *System) Serve(response http.ResponseWriter, fileKey string, name string) error {
|
||||
r, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
|
||||
@@ -182,14 +190,33 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
disposition := "attachment"
|
||||
realContentType := r.ContentType()
|
||||
if list.ExistInSlice(realContentType, inlineServeContentTypes) {
|
||||
disposition = "inline"
|
||||
}
|
||||
|
||||
// make an exception for svg and use a custom content type
|
||||
// to send in the response so that it can be loaded in a img tag
|
||||
// (see https://github.com/whatwg/mimesniff/issues/7)
|
||||
extContentType := realContentType
|
||||
if filepath.Ext(name) == ".svg" {
|
||||
extContentType = "image/svg+xml"
|
||||
}
|
||||
|
||||
response.Header().Set("Content-Disposition", disposition+"; filename="+name)
|
||||
response.Header().Set("Content-Type", extContentType)
|
||||
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
|
||||
response.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
|
||||
// All HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
|
||||
// (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1)
|
||||
location, _ := time.LoadLocation("GMT")
|
||||
|
||||
response.Header().Set("Content-Disposition", "attachment; filename="+name)
|
||||
response.Header().Set("Content-Type", r.ContentType())
|
||||
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
|
||||
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
|
||||
//
|
||||
// NB! time.LoadLocation may fail on non-Unix systems (see https://github.com/pocketbase/pocketbase/issues/45)
|
||||
location, locationErr := time.LoadLocation("GMT")
|
||||
if locationErr == nil {
|
||||
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
|
||||
}
|
||||
|
||||
// copy from the read range to response.
|
||||
_, err := io.Copy(response, r)
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestFileSystemExists(t *testing.T) {
|
||||
{"sub1.txt", false},
|
||||
{"test/sub1.txt", true},
|
||||
{"test/sub2.txt", true},
|
||||
{"file.png", true},
|
||||
{"image.png", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -51,13 +51,14 @@ func TestFileSystemAttributes(t *testing.T) {
|
||||
defer fs.Close()
|
||||
|
||||
scenarios := []struct {
|
||||
file string
|
||||
expectError bool
|
||||
file string
|
||||
expectError bool
|
||||
expectContentType string
|
||||
}{
|
||||
{"sub1.txt", true},
|
||||
{"test/sub1.txt", false},
|
||||
{"test/sub2.txt", false},
|
||||
{"file.png", false},
|
||||
{"sub1.txt", true, ""},
|
||||
{"test/sub1.txt", false, "application/octet-stream"},
|
||||
{"test/sub2.txt", false, "application/octet-stream"},
|
||||
{"image.png", false, "image/png"},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -71,8 +72,8 @@ func TestFileSystemAttributes(t *testing.T) {
|
||||
t.Errorf("(%d) Expected nil, got error, %v", i, err)
|
||||
}
|
||||
|
||||
if err == nil && attr.ContentType != "application/octet-stream" {
|
||||
t.Errorf("(%d) Expected attr.ContentType to be %q, got %q", i, "application/octet-stream", attr.ContentType)
|
||||
if err == nil && attr.ContentType != scenario.expectContentType {
|
||||
t.Errorf("(%d) Expected attr.ContentType to be %q, got %q", i, scenario.expectContentType, attr.ContentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +92,7 @@ func TestFileSystemDelete(t *testing.T) {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if err := fs.Delete("file.png"); err != nil {
|
||||
if err := fs.Delete("image.png"); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
}
|
||||
@@ -157,33 +158,73 @@ func TestFileSystemServe(t *testing.T) {
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
r := httptest.NewRecorder()
|
||||
|
||||
// serve missing file
|
||||
if err := fs.Serve(r, "missing.txt", "download.txt"); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// serve existing file
|
||||
if err := fs.Serve(r, "test/sub1.txt", "download.txt"); err != nil {
|
||||
t.Fatal("Expected nil, got error")
|
||||
}
|
||||
|
||||
result := r.Result()
|
||||
|
||||
// check headers
|
||||
scenarios := []struct {
|
||||
header string
|
||||
expected string
|
||||
path string
|
||||
name string
|
||||
expectError bool
|
||||
expectHeaders map[string]string
|
||||
}{
|
||||
{"Content-Disposition", "attachment; filename=download.txt"},
|
||||
{"Content-Type", "application/octet-stream"},
|
||||
{"Content-Length", "0"},
|
||||
{
|
||||
// missing
|
||||
"missing.txt",
|
||||
"test_name.txt",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
// existing regular file
|
||||
"test/sub1.txt",
|
||||
"test_name.txt",
|
||||
false,
|
||||
map[string]string{
|
||||
"Content-Disposition": "attachment; filename=test_name.txt",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": "0",
|
||||
},
|
||||
},
|
||||
// png inline
|
||||
{
|
||||
// svg exception
|
||||
"image.png",
|
||||
"test_name.png",
|
||||
false,
|
||||
map[string]string{
|
||||
"Content-Disposition": "inline; filename=test_name.png",
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": "73",
|
||||
},
|
||||
},
|
||||
{
|
||||
// svg exception
|
||||
"image.svg",
|
||||
"test_name.svg",
|
||||
false,
|
||||
map[string]string{
|
||||
"Content-Disposition": "attachment; filename=test_name.svg",
|
||||
"Content-Type": "image/svg+xml",
|
||||
"Content-Length": "0",
|
||||
},
|
||||
},
|
||||
}
|
||||
for i, scenario := range scenarios {
|
||||
v := result.Header.Get(scenario.header)
|
||||
if v != scenario.expected {
|
||||
t.Errorf("(%d) Expected value %q for header %q, got %q", i, scenario.expected, scenario.header, v)
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
r := httptest.NewRecorder()
|
||||
|
||||
err := fs.Serve(r, scenario.path, scenario.name)
|
||||
hasErr := err != nil
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%s) Expected hasError %v, got %v", scenario.path, scenario.expectError, hasErr)
|
||||
continue
|
||||
}
|
||||
|
||||
result := r.Result()
|
||||
|
||||
for hName, hValue := range scenario.expectHeaders {
|
||||
v := result.Header.Get(hName)
|
||||
if v != hValue {
|
||||
t.Errorf("(%s) Expected value %q for header %q, got %q", scenario.path, hValue, hName, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,11 +250,11 @@ func TestFileSystemCreateThumb(t *testing.T) {
|
||||
// non-image existing file
|
||||
{"test/sub1.txt", "thumb_test_sub1", true, true},
|
||||
// existing image file - crop center
|
||||
{"file.png", "thumb_file_center", true, false},
|
||||
{"image.png", "thumb_file_center", true, false},
|
||||
// existing image file - crop top
|
||||
{"file.png", "thumb_file_top", false, false},
|
||||
{"image.png", "thumb_file_top", false, false},
|
||||
// existing image file with existing thumb path = should fail
|
||||
{"file.png", "test", true, true},
|
||||
{"image.png", "test", true, true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -259,7 +300,7 @@ func createTestDir(t *testing.T) string {
|
||||
}
|
||||
file2.Close()
|
||||
|
||||
file3, err := os.OpenFile(filepath.Join(dir, "file.png"), os.O_WRONLY|os.O_CREATE, 0666)
|
||||
file3, err := os.OpenFile(filepath.Join(dir, "image.png"), os.O_WRONLY|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -267,6 +308,16 @@ func createTestDir(t *testing.T) string {
|
||||
imgRect := image.Rect(0, 0, 1, 1)
|
||||
png.Encode(file3, imgRect)
|
||||
file3.Close()
|
||||
err2 := os.WriteFile(filepath.Join(dir, "image.png.attrs"), []byte(`{"user.cache_control":"","user.content_disposition":"","user.content_encoding":"","user.content_language":"","user.content_type":"image/png","user.metadata":null}`), 0666)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
|
||||
file4, err := os.OpenFile(filepath.Join(dir, "image.svg"), os.O_WRONLY|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file4.Close()
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
+5
-2
@@ -44,8 +44,11 @@ func (h *Hook[T]) Reset() {
|
||||
// - any non-nil error is returned in one of the handlers
|
||||
func (h *Hook[T]) Trigger(data T, oneOffHandlers ...Handler[T]) error {
|
||||
h.mux.Lock()
|
||||
handlers := append(h.handlers, oneOffHandlers...)
|
||||
h.mux.Unlock() // unlock is not deferred to avoid deadlocks when Trigger is called recursive in the handlers
|
||||
handlers := make([]Handler[T], 0, len(h.handlers)+len(oneOffHandlers))
|
||||
handlers = append(handlers, h.handlers...)
|
||||
handlers = append(handlers, oneOffHandlers...)
|
||||
// unlock is not deferred to avoid deadlocks when Trigger is called recursive by the handlers
|
||||
h.mux.Unlock()
|
||||
|
||||
for _, fn := range handlers {
|
||||
err := fn(data)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
var columnifyRemoveRegex = regexp.MustCompile(`[^\w\.\*\-\_\@\#]+`)
|
||||
var snakecaseSplitRegex = regexp.MustCompile(`[\W_]+`)
|
||||
var usernamifySplitRegex = regexp.MustCompile(`\W+`)
|
||||
|
||||
// UcFirst converts the first character of a string into uppercase.
|
||||
func UcFirst(str string) string {
|
||||
@@ -33,15 +32,14 @@ func Sentenize(str string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
s := []rune(str)
|
||||
sentence := string(unicode.ToUpper(s[0])) + string(s[1:])
|
||||
str = UcFirst(str)
|
||||
|
||||
lastChar := string(s[len(s)-1:])
|
||||
lastChar := str[len(str)-1:]
|
||||
if lastChar != "." && lastChar != "?" && lastChar != "!" {
|
||||
return sentence + "."
|
||||
return str + "."
|
||||
}
|
||||
|
||||
return sentence
|
||||
return str
|
||||
}
|
||||
|
||||
// Sanitize sanitizes `str` by removing all characters satisfying `removePattern`.
|
||||
@@ -85,34 +83,3 @@ func Snakecase(str string) string {
|
||||
|
||||
return strings.ToLower(result.String())
|
||||
}
|
||||
|
||||
// Usernamify generates a properly formatted username from the provided string.
|
||||
// Returns "unknown" if `str` is empty or contains only non word characters.
|
||||
//
|
||||
// ```go
|
||||
// Usernamify("John Doe, hello") // "john.doe.hello"
|
||||
// ```
|
||||
func Usernamify(str string) string {
|
||||
// split at any non word character
|
||||
words := usernamifySplitRegex.Split(strings.ToLower(str), -1)
|
||||
|
||||
// concatenate any non empty word with a dot
|
||||
var result strings.Builder
|
||||
for _, word := range words {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if result.Len() > 0 {
|
||||
result.WriteString(".")
|
||||
}
|
||||
|
||||
result.WriteString(word)
|
||||
}
|
||||
|
||||
if result.Len() == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestUcFirst(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{" ", " "},
|
||||
{"Test", "Test"},
|
||||
{"test", "Test"},
|
||||
{"test test2", "Test test2"},
|
||||
@@ -55,6 +56,9 @@ func TestSentenize(t *testing.T) {
|
||||
}{
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{".", "."},
|
||||
{"?", "?"},
|
||||
{"!", "!"},
|
||||
{"Test", "Test."},
|
||||
{" test ", "Test."},
|
||||
{"hello world", "Hello world."},
|
||||
@@ -82,7 +86,7 @@ func TestSanitize(t *testing.T) {
|
||||
{" ", ` `, "", false},
|
||||
{"", `[A-Z]`, "", false},
|
||||
{"abcABC", `[A-Z]`, "abc", false},
|
||||
{"abcABC", `[A-Z`, "", true}, // invlid pattern
|
||||
{"abcABC", `[A-Z`, "", true}, // invalid pattern
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -128,26 +132,3 @@ func TestSnakecase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernamify(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
val string
|
||||
expected string
|
||||
}{
|
||||
{"", "unknown"},
|
||||
{" ", "unknown"},
|
||||
{"!@#$%^", "unknown"},
|
||||
{"...", "unknown"},
|
||||
{"_", "_"}, // underscore is valid word character
|
||||
{"John Doe", "john.doe"},
|
||||
{"John_Doe", "john_doe"},
|
||||
{".a!b@c#d$e%123. ", "a.b.c.d.e.123"},
|
||||
{"Hello, world", "hello.world"},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
if result := inflector.Usernamify(scenario.val); result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-29
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
var cachedPatterns = map[string]*regexp.Regexp{}
|
||||
|
||||
// ExustInSlice checks whether a comparable element exists in a slice of the same type.
|
||||
// ExistInSlice checks whether a comparable element exists in a slice of the same type.
|
||||
func ExistInSlice[T comparable](item T, list []T) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
@@ -38,21 +38,23 @@ func ExistInSliceWithRegex(str string, list []string) bool {
|
||||
if str == field {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// check for regex match
|
||||
pattern, ok := cachedPatterns[field]
|
||||
if !ok {
|
||||
var patternErr error
|
||||
pattern, patternErr = regexp.Compile(field)
|
||||
if patternErr != nil {
|
||||
continue
|
||||
}
|
||||
// "cache" the pattern to avoid compiling it every time
|
||||
cachedPatterns[field] = pattern
|
||||
}
|
||||
if pattern != nil && pattern.MatchString(str) {
|
||||
return true
|
||||
continue
|
||||
}
|
||||
|
||||
// check for regex match
|
||||
pattern, ok := cachedPatterns[field]
|
||||
if !ok {
|
||||
var err error
|
||||
pattern, err = regexp.Compile(field)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// "cache" the pattern to avoid compiling it every time
|
||||
cachedPatterns[field] = pattern
|
||||
}
|
||||
|
||||
if pattern != nil && pattern.MatchString(str) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,46 +74,45 @@ func ToInterfaceSlice[T any](list []T) []any {
|
||||
|
||||
// NonzeroUniques returns only the nonzero unique values from a slice.
|
||||
func NonzeroUniques[T comparable](list []T) []T {
|
||||
result := []T{}
|
||||
existMap := map[T]bool{}
|
||||
result := make([]T, 0, len(list))
|
||||
existMap := make(map[T]struct{}, len(list))
|
||||
|
||||
var zeroVal T
|
||||
|
||||
for _, val := range list {
|
||||
if !existMap[val] && val != zeroVal {
|
||||
existMap[val] = true
|
||||
result = append(result, val)
|
||||
if _, ok := existMap[val]; ok || val == zeroVal {
|
||||
continue
|
||||
}
|
||||
existMap[val] = struct{}{}
|
||||
result = append(result, val)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ToUniqueStringSlice casts `value` to a slice of non-zero unique strings.
|
||||
func ToUniqueStringSlice(value any) []string {
|
||||
strings := []string{}
|
||||
|
||||
func ToUniqueStringSlice(value any) (result []string) {
|
||||
switch val := value.(type) {
|
||||
case nil:
|
||||
// nothing to cast
|
||||
case []string:
|
||||
strings = val
|
||||
result = val
|
||||
case string:
|
||||
if val == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// check if it is a json encoded array of strings
|
||||
if err := json.Unmarshal([]byte(val), &strings); err != nil {
|
||||
if err := json.Unmarshal([]byte(val), &result); err != nil {
|
||||
// not a json array, just add the string as single array element
|
||||
strings = append(strings, val)
|
||||
result = append(result, val)
|
||||
}
|
||||
case json.Marshaler: // eg. JsonArray
|
||||
raw, _ := val.MarshalJSON()
|
||||
json.Unmarshal(raw, &strings)
|
||||
_ = json.Unmarshal(raw, &result)
|
||||
default:
|
||||
strings = cast.ToStringSlice(value)
|
||||
result = cast.ToStringSlice(value)
|
||||
}
|
||||
|
||||
return NonzeroUniques(strings)
|
||||
return NonzeroUniques(result)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ func NewSmtpClient(
|
||||
// SmtpClient defines a SMTP mail client structure that implements
|
||||
// `mailer.Mailer` interface.
|
||||
type SmtpClient struct {
|
||||
mail *mailyak.MailYak
|
||||
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
|
||||
@@ -194,11 +194,9 @@ func (r *Runner) Up() ([]string, error) {
|
||||
//
|
||||
// On success returns list with the reverted migrations file names.
|
||||
func (r *Runner) Down(toRevertCount int) ([]string, error) {
|
||||
applied := []string{}
|
||||
reverted := make([]string, 0, toRevertCount)
|
||||
|
||||
err := r.db.Transactional(func(tx *dbx.Tx) error {
|
||||
totalReverted := 0
|
||||
|
||||
for i := len(r.migrationsList.Items()) - 1; i >= 0; i-- {
|
||||
m := r.migrationsList.Item(i)
|
||||
|
||||
@@ -208,7 +206,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
|
||||
}
|
||||
|
||||
// revert limit reached
|
||||
if toRevertCount-totalReverted <= 0 {
|
||||
if toRevertCount-len(reverted) <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -220,7 +218,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
|
||||
return fmt.Errorf("Failed to save reverted migration info for %s: %w", m.file, err)
|
||||
}
|
||||
|
||||
applied = append(applied, m.file)
|
||||
reverted = append(reverted, m.file)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -229,7 +227,7 @@ func (r *Runner) Down(toRevertCount int) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return applied, nil
|
||||
return reverted, nil
|
||||
}
|
||||
|
||||
func (r *Runner) createMigrationsTable() error {
|
||||
|
||||
@@ -104,7 +104,8 @@ func TestRunnerUpAndDown(t *testing.T) {
|
||||
|
||||
// Down()
|
||||
// ---
|
||||
if _, err := r.Down(2); err != nil {
|
||||
// revert one migration
|
||||
if _, err := r.Down(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -112,8 +113,12 @@ func TestRunnerUpAndDown(t *testing.T) {
|
||||
t.Fatal("Didn't expect 3_test to be reverted.")
|
||||
}
|
||||
|
||||
if !test1DownCalled || !test2DownCalled {
|
||||
t.Fatalf("Expected 1_test and 2_test to be reverted, got %v and %v", test1DownCalled, test2DownCalled)
|
||||
if !test2DownCalled {
|
||||
t.Fatal("Expected 2_test to be reverted.")
|
||||
}
|
||||
|
||||
if test1DownCalled {
|
||||
t.Fatal("Didn't expect 1_test to be reverted.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
// will be used when parsing a form request body.
|
||||
const DefaultMaxMemory = 32 << 20 // 32mb
|
||||
|
||||
var extensionInvalidCharsRegex = regexp.MustCompile(`[^\w\.\*\-\+\=\#]+`)
|
||||
|
||||
// UploadedFile defines a single multipart uploaded file instance.
|
||||
type UploadedFile struct {
|
||||
name string
|
||||
@@ -65,8 +68,10 @@ func FindUploadedFiles(r *http.Request, key string) ([]*UploadedFile, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := extensionInvalidCharsRegex.ReplaceAllString(filepath.Ext(fh.Filename), "")
|
||||
|
||||
result[i] = &UploadedFile{
|
||||
name: fmt.Sprintf("%s%s", security.RandomString(32), filepath.Ext(fh.Filename)),
|
||||
name: fmt.Sprintf("%s%s", security.RandomString(32), ext),
|
||||
header: fh,
|
||||
bytes: buf.Bytes(),
|
||||
}
|
||||
|
||||
+27
-52
@@ -27,21 +27,16 @@ var parsedFilterData = store.New(make(map[string][]fexpr.ExprGroup, 50))
|
||||
// BuildExpr parses the current filter data and returns a new db WHERE expression.
|
||||
func (f FilterData) BuildExpr(fieldResolver FieldResolver) (dbx.Expression, error) {
|
||||
raw := string(f)
|
||||
var data []fexpr.ExprGroup
|
||||
|
||||
if parsedFilterData.Has(raw) {
|
||||
data = parsedFilterData.Get(raw)
|
||||
} else {
|
||||
var err error
|
||||
data, err = fexpr.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// store in cache
|
||||
// (the limit size is arbitrary and it is there to prevent the cache growing too big)
|
||||
parsedFilterData.SetIfLessThanLimit(raw, data, 500)
|
||||
return f.build(parsedFilterData.Get(raw), fieldResolver)
|
||||
}
|
||||
|
||||
data, err := fexpr.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// store in cache
|
||||
// (the limit size is arbitrary and it is there to prevent the cache growing too big)
|
||||
parsedFilterData.SetIfLessThanLimit(raw, data, 500)
|
||||
return f.build(data, fieldResolver)
|
||||
}
|
||||
|
||||
@@ -94,30 +89,18 @@ func (f FilterData) resolveTokenizedExpr(expr fexpr.Expr, fieldResolver FieldRes
|
||||
|
||||
// merge both operands parameters (if any)
|
||||
params := dbx.Params{}
|
||||
if len(lParams) > 0 {
|
||||
for k, v := range lParams {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range lParams {
|
||||
params[k] = v
|
||||
}
|
||||
if len(rParams) > 0 {
|
||||
for k, v := range rParams {
|
||||
params[k] = v
|
||||
}
|
||||
for k, v := range rParams {
|
||||
params[k] = v
|
||||
}
|
||||
|
||||
switch expr.Op {
|
||||
case fexpr.SignEq:
|
||||
op := "="
|
||||
if strings.ToLower(lName) == "null" || strings.ToLower(rName) == "null" {
|
||||
op = "IS"
|
||||
}
|
||||
return dbx.NewExp(fmt.Sprintf("%s %s %s", lName, op, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') = COALESCE(%s, '')", lName, rName), params), nil
|
||||
case fexpr.SignNeq:
|
||||
op := "!="
|
||||
if strings.ToLower(lName) == "null" || strings.ToLower(rName) == "null" {
|
||||
op = "IS NOT"
|
||||
}
|
||||
return dbx.NewExp(fmt.Sprintf("%s %s %s", lName, op, rName), params), nil
|
||||
return dbx.NewExp(fmt.Sprintf("COALESCE(%s, '') != COALESCE(%s, '')", lName, rName), params), nil
|
||||
case fexpr.SignLike:
|
||||
// normalize operands and switch sides if the left operand is a number or text
|
||||
if len(lParams) > 0 {
|
||||
@@ -144,35 +127,31 @@ func (f FilterData) resolveTokenizedExpr(expr fexpr.Expr, fieldResolver FieldRes
|
||||
}
|
||||
|
||||
func (f FilterData) resolveToken(token fexpr.Token, fieldResolver FieldResolver) (name string, params dbx.Params, err error) {
|
||||
if token.Type == fexpr.TokenIdentifier {
|
||||
switch token.Type {
|
||||
case fexpr.TokenIdentifier:
|
||||
name, params, err := fieldResolver.Resolve(token.Literal)
|
||||
|
||||
if name == "" || err != nil {
|
||||
// if `null` field is missing, treat `null` identifier as NULL token
|
||||
if strings.ToLower(token.Literal) == "null" {
|
||||
return "NULL", nil, nil
|
||||
m := map[string]string{
|
||||
// if `null` field is missing, treat `null` identifier as NULL token
|
||||
"null": "NULL",
|
||||
// if `true` field is missing, treat `true` identifier as TRUE token
|
||||
"true": "1",
|
||||
// if `false` field is missing, treat `false` identifier as FALSE token
|
||||
"false": "0",
|
||||
}
|
||||
|
||||
// if `true` field is missing, treat `true` identifier as TRUE token
|
||||
if strings.ToLower(token.Literal) == "true" {
|
||||
return "1", nil, nil
|
||||
if v, ok := m[strings.ToLower(token.Literal)]; ok {
|
||||
return v, nil, nil
|
||||
}
|
||||
|
||||
// if `false` field is missing, treat `false` identifier as FALSE token
|
||||
if strings.ToLower(token.Literal) == "false" {
|
||||
return "0", nil, nil
|
||||
}
|
||||
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return name, params, err
|
||||
}
|
||||
|
||||
if token.Type == fexpr.TokenNumber || token.Type == fexpr.TokenText {
|
||||
case fexpr.TokenNumber, fexpr.TokenText:
|
||||
placeholder := "t" + security.RandomString(7)
|
||||
name := fmt.Sprintf("{:%s}", placeholder)
|
||||
params := dbx.Params{placeholder: token.Literal}
|
||||
|
||||
return name, params, nil
|
||||
}
|
||||
|
||||
@@ -182,10 +161,6 @@ func (f FilterData) resolveToken(token fexpr.Token, fieldResolver FieldResolver)
|
||||
func (f FilterData) normalizeLikeParams(params dbx.Params) dbx.Params {
|
||||
result := dbx.Params{}
|
||||
|
||||
if len(params) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
vStr := cast.ToString(v)
|
||||
if !strings.Contains(vStr, "%") {
|
||||
|
||||
@@ -39,25 +39,25 @@ func TestFilterDataBuildExpr(t *testing.T) {
|
||||
"^" +
|
||||
regexp.QuoteMeta("((([[test1]] > {:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("}) OR ([[test2]] != {:") +
|
||||
regexp.QuoteMeta("}) OR (COALESCE([[test2]], '') != COALESCE({:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("})) AND ([[test3]] LIKE {:") +
|
||||
regexp.QuoteMeta("}, ''))) AND ([[test3]] LIKE {:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("})) AND ([[test4.sub]] IS NULL)") +
|
||||
regexp.QuoteMeta("})) AND (COALESCE([[test4.sub]], '') = COALESCE(NULL, ''))") +
|
||||
"$",
|
||||
},
|
||||
// combination of special literals (null, true, false)
|
||||
{
|
||||
"test1=true && test2 != false && test3 = null || test4.sub != null",
|
||||
false,
|
||||
"^" + regexp.QuoteMeta("((([[test1]] = 1) AND ([[test2]] != 0)) AND ([[test3]] IS NULL)) OR ([[test4.sub]] IS NOT NULL)") + "$",
|
||||
"^" + regexp.QuoteMeta("(((COALESCE([[test1]], '') = COALESCE(1, '')) AND (COALESCE([[test2]], '') != COALESCE(0, ''))) AND (COALESCE([[test3]], '') = COALESCE(NULL, ''))) OR (COALESCE([[test4.sub]], '') != COALESCE(NULL, ''))") + "$",
|
||||
},
|
||||
// all operators
|
||||
{
|
||||
"(test1 = test2 || test2 != test3) && (test2 ~ 'example' || test2 !~ '%%abc') && 'switch1%%' ~ test1 && 'switch2' !~ test2 && test3 > 1 && test3 >= 0 && test3 <= 4 && 2 < 5",
|
||||
false,
|
||||
"^" +
|
||||
regexp.QuoteMeta("(((((((([[test1]] = [[test2]]) OR ([[test2]] != [[test3]])) AND (([[test2]] LIKE {:") +
|
||||
regexp.QuoteMeta("((((((((COALESCE([[test1]], '') = COALESCE([[test2]], '')) OR (COALESCE([[test2]], '') != COALESCE([[test3]], ''))) AND (([[test2]] LIKE {:") +
|
||||
".+" +
|
||||
regexp.QuoteMeta("}) OR ([[test2]] NOT LIKE {:") +
|
||||
".+" +
|
||||
|
||||
+34
-31
@@ -37,6 +37,7 @@ type Provider struct {
|
||||
query *dbx.SelectQuery
|
||||
page int
|
||||
perPage int
|
||||
countColumn string
|
||||
sort []SortField
|
||||
filter []FilterData
|
||||
}
|
||||
@@ -67,6 +68,13 @@ func (s *Provider) Query(query *dbx.SelectQuery) *Provider {
|
||||
return s
|
||||
}
|
||||
|
||||
// CountColumn specifies an optional distinct column to use in the
|
||||
// SELECT COUNT query.
|
||||
func (s *Provider) CountColumn(countColumn string) *Provider {
|
||||
s.countColumn = countColumn
|
||||
return s
|
||||
}
|
||||
|
||||
// Page sets the `page` field of the current search provider.
|
||||
//
|
||||
// Normalization on the `page` value is done during `Exec()`.
|
||||
@@ -121,8 +129,7 @@ func (s *Provider) Parse(urlQuery string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
rawPage := params.Get(PageQueryParam)
|
||||
if rawPage != "" {
|
||||
if rawPage := params.Get(PageQueryParam); rawPage != "" {
|
||||
page, err := strconv.Atoi(rawPage)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -130,8 +137,7 @@ func (s *Provider) Parse(urlQuery string) error {
|
||||
s.Page(page)
|
||||
}
|
||||
|
||||
rawPerPage := params.Get(PerPageQueryParam)
|
||||
if rawPerPage != "" {
|
||||
if rawPerPage := params.Get(PerPageQueryParam); rawPerPage != "" {
|
||||
perPage, err := strconv.Atoi(rawPerPage)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -139,15 +145,13 @@ func (s *Provider) Parse(urlQuery string) error {
|
||||
s.PerPage(perPage)
|
||||
}
|
||||
|
||||
rawSort := params.Get(SortQueryParam)
|
||||
if rawSort != "" {
|
||||
if rawSort := params.Get(SortQueryParam); rawSort != "" {
|
||||
for _, sortField := range ParseSortFromString(rawSort) {
|
||||
s.AddSort(sortField)
|
||||
}
|
||||
}
|
||||
|
||||
rawFilter := params.Get(FilterQueryParam)
|
||||
if rawFilter != "" {
|
||||
if rawFilter := params.Get(FilterQueryParam); rawFilter != "" {
|
||||
s.AddFilter(FilterData(rawFilter))
|
||||
}
|
||||
|
||||
@@ -165,41 +169,40 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
modelsQuery := *s.query
|
||||
|
||||
// apply filters
|
||||
if len(s.filter) > 0 {
|
||||
for _, f := range s.filter {
|
||||
expr, err := f.BuildExpr(s.fieldResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != nil {
|
||||
modelsQuery.AndWhere(expr)
|
||||
}
|
||||
for _, f := range s.filter {
|
||||
expr, err := f.BuildExpr(s.fieldResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != nil {
|
||||
modelsQuery.AndWhere(expr)
|
||||
}
|
||||
}
|
||||
|
||||
// apply sorting
|
||||
if len(s.sort) > 0 {
|
||||
for _, sortField := range s.sort {
|
||||
expr, err := sortField.BuildExpr(s.fieldResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != "" {
|
||||
modelsQuery.AndOrderBy(expr)
|
||||
}
|
||||
for _, sortField := range s.sort {
|
||||
expr, err := sortField.BuildExpr(s.fieldResolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != "" {
|
||||
modelsQuery.AndOrderBy(expr)
|
||||
}
|
||||
}
|
||||
|
||||
// apply field resolver query modifications (if any)
|
||||
updateQueryErr := s.fieldResolver.UpdateQuery(&modelsQuery)
|
||||
if updateQueryErr != nil {
|
||||
return nil, updateQueryErr
|
||||
if err := s.fieldResolver.UpdateQuery(&modelsQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// count
|
||||
var totalCount int64
|
||||
countQuery := modelsQuery
|
||||
if err := countQuery.Select("count(*)").Row(&totalCount); err != nil {
|
||||
countQuery.Distinct(false).Select("COUNT(*)")
|
||||
if s.countColumn != "" {
|
||||
countQuery.Select("COUNT(DISTINCT(" + s.countColumn + "))")
|
||||
}
|
||||
if err := countQuery.Row(&totalCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -210,7 +213,7 @@ func (s *Provider) Exec(items any) (*Result, error) {
|
||||
s.perPage = MaxPerPage
|
||||
}
|
||||
|
||||
// normalize page accoring to the total count
|
||||
// normalize page according to the total count
|
||||
if s.page <= 0 || totalCount == 0 {
|
||||
s.page = 1
|
||||
} else if totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage))); s.page > totalPages {
|
||||
|
||||
@@ -60,6 +60,15 @@ func TestProviderPerPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCountColumn(t *testing.T) {
|
||||
r := &testFieldResolver{}
|
||||
p := NewProvider(r).CountColumn("test")
|
||||
|
||||
if p.countColumn != "test" {
|
||||
t.Fatalf("Expected distinct count column %v, got %v", "test", p.countColumn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSort(t *testing.T) {
|
||||
initialSort := []SortField{{"test1", SortAsc}, {"test2", SortAsc}}
|
||||
r := &testFieldResolver{}
|
||||
@@ -214,6 +223,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
perPage int
|
||||
sort []SortField
|
||||
filter []FilterData
|
||||
countColumn string
|
||||
expectError bool
|
||||
expectResult string
|
||||
expectQueries []string
|
||||
@@ -224,10 +234,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":10,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
|
||||
},
|
||||
},
|
||||
@@ -237,10 +248,11 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
0, // fallback to default
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":30,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
|
||||
},
|
||||
},
|
||||
@@ -250,6 +262,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{{"unknown", SortAsc}},
|
||||
[]FilterData{},
|
||||
"",
|
||||
true,
|
||||
"",
|
||||
nil,
|
||||
@@ -260,6 +273,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{},
|
||||
[]FilterData{"test2 = 'test2.1'", "invalid"},
|
||||
"",
|
||||
true,
|
||||
"",
|
||||
nil,
|
||||
@@ -270,11 +284,12 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
5555, // will be limited by MaxPerPage
|
||||
[]SortField{{"test2", SortDesc}},
|
||||
[]FilterData{"test2 != null", "test1 >= 2"},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT count(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (test2 IS NOT null)) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
|
||||
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (test2 IS NOT null)) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
|
||||
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
|
||||
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
|
||||
},
|
||||
},
|
||||
// valid sort and filter fields (zero results)
|
||||
@@ -283,11 +298,12 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
10,
|
||||
[]SortField{{"test3", SortAsc}},
|
||||
[]FilterData{"test3 != ''"},
|
||||
"",
|
||||
false,
|
||||
`{"page":1,"perPage":10,"totalItems":0,"items":[]}`,
|
||||
[]string{
|
||||
"SELECT count(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (test3 != '') ORDER BY `test1` ASC, `test3` ASC",
|
||||
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (test3 != '') ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
|
||||
"SELECT COUNT(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC",
|
||||
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
|
||||
},
|
||||
},
|
||||
// pagination test
|
||||
@@ -296,10 +312,25 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
1,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"",
|
||||
false,
|
||||
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT count(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||
},
|
||||
},
|
||||
// distinct count column
|
||||
{
|
||||
3,
|
||||
1,
|
||||
[]SortField{},
|
||||
[]FilterData{},
|
||||
"test.test1",
|
||||
false,
|
||||
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
|
||||
[]string{
|
||||
"SELECT COUNT(DISTINCT(test.test1)) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
|
||||
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
|
||||
},
|
||||
},
|
||||
@@ -314,7 +345,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
Page(s.page).
|
||||
PerPage(s.perPage).
|
||||
Sort(s.sort).
|
||||
Filter(s.filter)
|
||||
Filter(s.filter).
|
||||
CountColumn(s.countColumn)
|
||||
|
||||
result, err := p.Exec(&[]testTableStruct{})
|
||||
|
||||
@@ -344,7 +376,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
|
||||
|
||||
for _, q := range testDB.CalledQueries {
|
||||
if !list.ExistInSliceWithRegex(q, s.expectQueries) {
|
||||
t.Errorf("(%d) Didn't expect query %v", i, q)
|
||||
t.Errorf("(%d) Didn't expect query \n%v", i, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-12
@@ -34,26 +34,18 @@ func (s *SortField) BuildExpr(fieldResolver FieldResolver) (string, error) {
|
||||
//
|
||||
// Example:
|
||||
// fields := search.ParseSortFromString("-name,+created")
|
||||
func ParseSortFromString(str string) []SortField {
|
||||
result := []SortField{}
|
||||
|
||||
func ParseSortFromString(str string) (fields []SortField) {
|
||||
data := strings.Split(str, ",")
|
||||
|
||||
for _, field := range data {
|
||||
// trim whitespaces
|
||||
field = strings.TrimSpace(field)
|
||||
|
||||
var dir string
|
||||
if strings.HasPrefix(field, "-") {
|
||||
dir = SortDesc
|
||||
field = strings.TrimPrefix(field, "-")
|
||||
fields = append(fields, SortField{strings.TrimPrefix(field, "-"), SortDesc})
|
||||
} else {
|
||||
dir = SortAsc
|
||||
field = strings.TrimPrefix(field, "+")
|
||||
fields = append(fields, SortField{strings.TrimPrefix(field, "+"), SortAsc})
|
||||
}
|
||||
|
||||
result = append(result, SortField{field, dir})
|
||||
}
|
||||
|
||||
return result
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,10 +66,5 @@ func Decrypt(cipherText string, key string) ([]byte, error) {
|
||||
}
|
||||
|
||||
nonce, cipherByteClean := cipherByte[:nonceSize], cipherByte[nonceSize:]
|
||||
plainData, err := gcm.Open(nil, nonce, cipherByteClean, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plainData, nil
|
||||
return gcm.Open(nil, nonce, cipherByteClean, nil)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,7 @@ func ParseUnverifiedJWT(token string) (jwt.MapClaims, error) {
|
||||
|
||||
// ParseJWT verifies and parses JWT token and returns its claims.
|
||||
func ParseJWT(token string, verificationKey string) (jwt.MapClaims, error) {
|
||||
parser := &jwt.Parser{
|
||||
ValidMethods: []string{"HS256"},
|
||||
}
|
||||
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}))
|
||||
|
||||
parsedToken, err := parser.Parse(token, func(t *jwt.Token) (any, error) {
|
||||
return []byte(verificationKey), nil
|
||||
@@ -50,10 +48,8 @@ func NewToken(payload jwt.MapClaims, signingKey string, secondsDuration int64) (
|
||||
"exp": time.Now().Add(seconds).Unix(),
|
||||
}
|
||||
|
||||
if len(payload) > 0 {
|
||||
for k, v := range payload {
|
||||
claims[k] = v
|
||||
}
|
||||
for k, v := range payload {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
func TestRandomString(t *testing.T) {
|
||||
generated := []string{}
|
||||
reg := regexp.MustCompile(`[a-zA-Z0-9]+`)
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
length := 5 + i
|
||||
@@ -18,7 +19,7 @@ func TestRandomString(t *testing.T) {
|
||||
t.Errorf("(%d) Expected the length of the string to be %d, got %d", i, length, len(result))
|
||||
}
|
||||
|
||||
if match, _ := regexp.MatchString("[a-zA-Z0-9]+", result); !match {
|
||||
if match := reg.MatchString(result); !match {
|
||||
t.Errorf("(%d) The generated strings should have only [a-zA-Z0-9]+ characters, got %q", i, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ func New[T any](data map[string]T) *Store[T] {
|
||||
return &Store[T]{data: data}
|
||||
}
|
||||
|
||||
// RemoveAll removes all the existing store entries.
|
||||
func (s *Store[T]) RemoveAll() {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
s.data = make(map[string]T)
|
||||
}
|
||||
|
||||
// Remove removes a single entry from the store.
|
||||
//
|
||||
// Remove does nothing if key doesn't exist in the store.
|
||||
|
||||
@@ -14,6 +14,20 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAll(t *testing.T) {
|
||||
s := store.New(map[string]bool{"test1": true, "test2": true})
|
||||
|
||||
keys := []string{"test1", "test2"}
|
||||
|
||||
s.RemoveAll()
|
||||
|
||||
for i, key := range keys {
|
||||
if s.Has(key) {
|
||||
t.Errorf("(%d) Expected %q to be removed", i, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s := store.New(map[string]bool{"test": true})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestNowDateTime(t *testing.T) {
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
PB_BACKEND_URL = /
|
||||
PB_PROFILE_COLLECTION = profiles
|
||||
PB_RULES_SYNTAX_DOCS = https://pocketbase.io/docs/manage-collections#rules-filters-syntax
|
||||
PB_INSTALLER_PARAM = installer
|
||||
|
||||
Vendored
-68
File diff suppressed because one or more lines are too long
+8
-8
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
||||
import{S as t,i as n,s as o,K as a}from"./index.66a4e29c.js";function l(e){return a("/collections"),[]}class c extends t{constructor(s){super(),n(this,s,l,null,o,{})}}export{c as default};
|
||||
@@ -0,0 +1,2 @@
|
||||
import{S as E,i as G,s as I,F as K,c as F,m as B,t as H,a as N,d as T,C as M,q as J,e as c,w as q,b as k,f as u,r as L,g as b,h as _,u as h,v as O,j as Q,l as U,o as w,A as V,p as W,B as X,D as Y,x as Z,z as S}from"./index.38300823.js";function y(f){let e,o,s;return{c(){e=q("for "),o=c("strong"),s=q(f[3]),u(o,"class","txt-nowrap")},m(l,t){b(l,e,t),b(l,o,t),_(o,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&w(e),l&&w(o)}}}function x(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0,t.autofocus=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[0]),t.focus(),p||(d=h(t,"input",f[6]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&1&&t.value!==n[0]&&S(t,n[0])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function ee(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password confirm"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[1]),p||(d=h(t,"input",f[7]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&2&&t.value!==n[1]&&S(t,n[1])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function te(f){let e,o,s,l,t,r,p,d,n,i,g,R,C,v,P,A,j,m=f[3]&&y(f);return r=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),d=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),{c(){e=c("form"),o=c("div"),s=c("h4"),l=q(`Reset your admin password
|
||||
`),m&&m.c(),t=k(),F(r.$$.fragment),p=k(),F(d.$$.fragment),n=k(),i=c("button"),g=c("span"),g.textContent="Set new password",R=k(),C=c("div"),v=c("a"),v.textContent="Back to login",u(s,"class","m-b-xs"),u(o,"class","content txt-center m-b-sm"),u(g,"class","txt"),u(i,"type","submit"),u(i,"class","btn btn-lg btn-block"),i.disabled=f[2],L(i,"btn-loading",f[2]),u(e,"class","m-b-base"),u(v,"href","/login"),u(v,"class","link-hint"),u(C,"class","content txt-center")},m(a,$){b(a,e,$),_(e,o),_(o,s),_(s,l),m&&m.m(s,null),_(e,t),B(r,e,null),_(e,p),B(d,e,null),_(e,n),_(e,i),_(i,g),b(a,R,$),b(a,C,$),_(C,v),P=!0,A||(j=[h(e,"submit",O(f[4])),Q(U.call(null,v))],A=!0)},p(a,$){a[3]?m?m.p(a,$):(m=y(a),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const z={};$&769&&(z.$$scope={dirty:$,ctx:a}),r.$set(z);const D={};$&770&&(D.$$scope={dirty:$,ctx:a}),d.$set(D),(!P||$&4)&&(i.disabled=a[2]),$&4&&L(i,"btn-loading",a[2])},i(a){P||(H(r.$$.fragment,a),H(d.$$.fragment,a),P=!0)},o(a){N(r.$$.fragment,a),N(d.$$.fragment,a),P=!1},d(a){a&&w(e),m&&m.d(),T(r),T(d),a&&w(R),a&&w(C),A=!1,V(j)}}}function se(f){let e,o;return e=new K({props:{$$slots:{default:[te]},$$scope:{ctx:f}}}),{c(){F(e.$$.fragment)},m(s,l){B(e,s,l),o=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){o||(H(e.$$.fragment,s),o=!0)},o(s){N(e.$$.fragment,s),o=!1},d(s){T(e,s)}}}function le(f,e,o){let s,{params:l}=e,t="",r="",p=!1;async function d(){if(!p){o(2,p=!0);try{await W.Admins.confirmPasswordReset(l==null?void 0:l.token,t,r),X("Successfully set a new admin password."),Y("/")}catch(g){W.errorResponseHandler(g)}o(2,p=!1)}}function n(){t=this.value,o(0,t)}function i(){r=this.value,o(1,r)}return f.$$set=g=>{"params"in g&&o(5,l=g.params)},f.$$.update=()=>{f.$$.dirty&32&&o(3,s=M.getJWTPayload(l==null?void 0:l.token).email||"")},[t,r,p,s,d,l,n,i]}class ae extends E{constructor(e){super(),G(this,e,le,se,I,{params:5})}}export{ae as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user