Compare commits

..
54 Commits
Author SHA1 Message Date
Gani Georgiev 4c2cd5a534 simplify the svg extension check 2022-07-21 12:58:06 +03:00
Gani Georgiev 5d8fc939e2 [#164] serve common media files inline and fix svg content-type 2022-07-21 12:56:17 +03:00
Gani Georgiev 1a5180d7d3 added support to filter request.user.profile relation fields 2022-07-20 22:33:24 +03:00
Gani Georgiev 8a08a4764d [#166] fixed api preview examples 2022-07-19 19:41:03 +03:00
Gani Georgiev 66b317f01c run tests before goreleaser 2022-07-19 17:24:58 +03:00
Gani Georgiev ab5a770346 updated tests 2022-07-19 17:23:34 +03:00
Gani Georgiev 65697add43 temporary skip tests in the release action until the async email hooks get fixed 2022-07-19 14:46:02 +03:00
Gani Georgiev 841415f0ff move field delete in a dropdown to prevent accidental clicks 2022-07-19 14:36:35 +03:00
Gani Georgiev f295ce9403 run added password reset and verification sent hooks tests 2022-07-19 14:20:28 +03:00
Gani Georgiev 383b2a1279 [#160] support expand query parameter for create and update requests 2022-07-19 13:31:52 +03:00
Gani Georgiev 73fb12c2bc [#156] added forcePathStyle to the s3 config 2022-07-19 10:45:38 +03:00
Kenneth Lee 571c4dcc8d [#163] fixed migrate down cmd 2022-07-18 23:00:54 +03:00
Gani Georgiev f56adf26f4 added the app name in the document title and fixed the double initial load on records list 2022-07-18 19:44:10 +03:00
Gani Georgiev f8f3ca25ee updated duplicated field name error 2022-07-18 18:10:30 +03:00
Gani Georgiev e01f76d37b allow switching schema field names when renaming fields 2022-07-18 16:26:37 +03:00
Gani Georgiev 47fc9b1066 normalized null handling in search filters 2022-07-18 14:07:25 +03:00
Gani Georgiev eaf08a5c15 [#151] updated the tests to ensure that the cascaded record files are also deleted 2022-07-18 13:19:07 +03:00
Gani Georgiev 8ef3d4e966 [#151] remove files on cascade deletion 2022-07-18 12:04:27 +03:00
Gani Georgiev 04e0cec32c updated ui/dist 2022-07-18 01:06:55 +03:00
Gani Georgiev 9a231ba7b3 applied some of the changes from #149 2022-07-18 01:03:09 +03:00
Gani Georgiev 7f959011b8 moved field option btns inside the panel 2022-07-18 00:55:53 +03:00
Gani Georgiev 994761b728 normalized the caster to return always non-null value and fixed minor ui issues 2022-07-18 00:16:09 +03:00
Gani Georgiev f19b9e3552 commented golangci-lint action as it is not go1.18+ ready 2022-07-17 22:25:28 +03:00
Gani Georgiev 36783b8f04 added golangci-lint action 2022-07-17 22:20:40 +03:00
Gani Georgiev a076cc906f [#147] added CONTRIBUTING.md 2022-07-17 20:33:12 +03:00
Gani Georgiev b1a30f4050 fixed figure tag typo 2022-07-16 11:57:31 +03:00
Gani Georgiev 4506fb17e9 adding video presets to the file options 2022-07-16 11:54:54 +03:00
Gani Georgiev 72f72bc84f added invalid form-group styles 2022-07-16 10:40:04 +03:00
Gani Georgiev 7fd5102fb5 updated EmailOptions domain input tooltips 2022-07-16 10:39:19 +03:00
Gani Georgiev d6bdc51009 changed the storage slide panel transition to local 2022-07-16 10:38:47 +03:00
Gani Georgiev 789373d15d [#100] resets the initial loadList results 2022-07-15 19:37:30 +03:00
Gani Georgiev 2dc000da65 improve error reporting on OAuth2 user profile fetch 2022-07-15 18:52:37 +03:00
Simon Krauter 1095637bcd [#116] fix BaseModel.RefreshUpdated comment 2022-07-14 23:17:53 +03:00
Gani Georgiev c4fcba5210 [#109] prealocated handlers and replaced OnRecordBeforeDeleteRequest with OnModelBeforeDelete 2022-07-14 22:35:57 +03:00
Gani Georgiev d8c8289269 added odd default circle icon size for better visual vertical alignment in firefox 2022-07-14 21:21:47 +03:00
Gani Georgiev dbbfa243bc added new lines for readability and consistency 2022-07-14 20:01:53 +03:00
Valley a16b0c9004 [#114] simplified some code by returning early and added cap for slices 2022-07-14 19:26:08 +03:00
Gani Georgiev 03a7bafa66 use the original vite2 default port and bump the min js-sdk to v0.2.1 2022-07-14 17:09:08 +03:00
Gani Georgiev d129959098 added store.RemoveAll() helper method 2022-07-14 16:39:42 +03:00
Gani Georgiev 6749559a22 log the response error not the handler one 2022-07-14 11:52:35 +03:00
Gani Georgiev 28bc2678e9 minor ui improvements and upgraded dependencies 2022-07-14 09:58:53 +03:00
Gani Georgiev d4202e696b [#99] fix AutoExpandTextArea scrolling 2022-07-13 22:44:59 +03:00
Gani Georgiev 9de3cc99a0 updated api preview body params col sizes 2022-07-13 08:48:38 +03:00
Franco Profeti 111bc59472 [#93] improved the README for no golang devs 2022-07-13 08:12:25 +03:00
Gani Georgiev b2647ebca9 synced refresh button js timeout with the css animation 2022-07-12 20:03:13 +03:00
Gani Georgiev ef226cf9c1 added refresh button to the other listing pages 2022-07-12 19:56:22 +03:00
Valley 63d5a8d633 [#89] simplified some code by returning early and reducing local variable scopes 2022-07-12 19:52:09 +03:00
Gani Georgiev d71c3cd19c updated ui/dist 2022-07-12 18:11:50 +03:00
Cornelius Müller 240bd6790a [#83] added refresh button to page records view 2022-07-12 18:08:57 +03:00
Gani Georgiev 05a4071eba [#80] fixed before hooks data and added optional interceptor to upsert submit 2022-07-12 13:42:06 +03:00
Valley ce857985be [#82] removed version cmd and make use of cobra.Version 2022-07-12 07:57:36 +03:00
Gani Georgiev 320d1482a4 [#77] add warning on storage type change 2022-07-11 22:23:22 +03:00
Gani Georgiev 46399dddac [#78] enable fully qualified URIs for S3 endpoints and improved error reporting when uploading or deleting files 2022-07-11 21:00:17 +03:00
Gani Georgiev 52c288d9db added linter skip comments and removed the Presentator specific inflector.Usernamify 2022-07-11 16:16:01 +03:00
133 changed files with 3012 additions and 2070 deletions
+8
View File
@@ -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:
+80
View File
@@ -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.
+6 -3
View File
@@ -91,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.
@@ -118,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.
+26 -18
View File
@@ -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 {
+24 -17
View File
@@ -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)
}
},
},
}
@@ -507,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",
@@ -532,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",
@@ -647,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,
+1 -1
View File
@@ -68,7 +68,7 @@ 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)
}
}
+28 -44
View File
@@ -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
}
-9
View File
@@ -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",
+1 -1
View File
@@ -77,7 +77,7 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
}
// RequireAdminAuthIfAny middleware requires a request to have
// 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 {
+28 -21
View File
@@ -159,8 +159,8 @@ 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
switch modelTable {
@@ -174,8 +174,8 @@ func (api *realtimeApi) bindEvents() {
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)
}
}
@@ -183,8 +183,8 @@ 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
switch modelTable {
@@ -198,7 +198,7 @@ func (api *realtimeApi) bindEvents() {
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())
}
}
@@ -206,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
})
}
@@ -286,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)
@@ -312,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
}
+82 -60
View File
@@ -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
View File
@@ -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,
+18 -11
View File
@@ -40,6 +40,8 @@ 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 occurred while reading the submitted data.", err)
}
@@ -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 occurred 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
}
-3
View File
@@ -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",
+26 -18
View File
@@ -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
View File
@@ -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",
-21
View File
@@ -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 %s\n", version)
},
}
}
+37 -1
View File
@@ -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
})
}
+8 -7
View File
@@ -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)),
+14 -2
View File
@@ -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,
+4 -7
View File
@@ -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
}
}
+1 -1
View File
@@ -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" {
+1 -3
View File
@@ -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{}
+32 -6
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
}
+1
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+8 -3
View File
@@ -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...)
}
+65 -1
View File
@@ -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")
}
}
+19
View File
@@ -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()
}
+7 -17
View File
@@ -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)),
@@ -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...)
}
+71 -3
View File
@@ -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")
}
}
+98 -81
View File
@@ -3,6 +3,7 @@ package forms
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
@@ -149,76 +150,77 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
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
}
}
@@ -270,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
}
@@ -280,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 {
@@ -316,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
@@ -347,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
+86 -6
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -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
View File
@@ -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...)
}
+58 -1
View File
@@ -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")
}
}
+25 -24
View File
@@ -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
+7 -2
View File
@@ -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...)
}
+65 -1
View File
@@ -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")
}
}
+18 -32
View File
@@ -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
}
+62 -62
View File
@@ -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,
+7 -1
View File
@@ -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
@@ -71,7 +77,7 @@ func (m *BaseModel) RefreshCreated() {
m.Created = types.NowDateTime()
}
// RefreshUpdated 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()
}
+1
View File
@@ -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
+4 -3
View File
@@ -16,6 +16,7 @@ import (
var _ Model = (*Record)(nil)
var _ ColumnValueMapper = (*Record)(nil)
var _ FilesManager = (*Record)(nil)
type Record struct {
BaseModel
@@ -68,10 +69,10 @@ 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
+3 -3
View File
@@ -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))
}
}
+13 -25
View File
@@ -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
+20 -13
View File
@@ -498,19 +498,22 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{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"`},
{
+2 -2
View File
@@ -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"
)
+7 -6
View File
@@ -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()
+70 -31
View File
@@ -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)
}
+116 -63
View File
@@ -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 {
+28 -8
View File
@@ -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
}
}
Binary file not shown.
+11
View File
@@ -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)
}
+29 -6
View File
@@ -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
@@ -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,9 +190,24 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
}
defer r.Close()
response.Header().Set("Content-Disposition", "attachment; filename="+name)
response.Header().Set("Content-Type", r.ContentType())
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)
+89 -38
View File
@@ -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
View File
@@ -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)
+4 -37
View File
@@ -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()
}
+4 -23
View File
@@ -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."},
@@ -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)
}
}
}
+29 -28
View File
@@ -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)
}
+4 -6
View File
@@ -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 {
+8 -3
View File
@@ -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.")
}
}
+6 -1
View File
@@ -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
View File
@@ -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, "%") {
+5 -5
View File
@@ -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 {:") +
".+" +
+33 -30
View File
@@ -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
}
+41 -9
View File
@@ -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
View File
@@ -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
}
+1 -6
View File
@@ -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)
}
+3 -7
View File
@@ -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))
+8
View File
@@ -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
View File
@@ -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})
File diff suppressed because one or more lines are too long
@@ -1,2 +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,w as q,e as c,f as u,g as b,h as _,x as O,o as w,q as J,b as k,r as L,u as h,v as Q,j as U,l as V,A as X,p as W,B as Y,D as Z,z as S}from"./index.6f2d3c23.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&&O(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",Q(f[4])),U(V.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,X(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),Y("Successfully set a new admin password."),Z("/")}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};
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};
@@ -1,2 +1,2 @@
import{S as E,i as M,s as T,F as j,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as z,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as A,v as I,w as h,x as J,y as P,z as R}from"./index.6f2d3c23.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`<h4 class="m-b-xs">Forgotten admin password</h4>
import{S as E,i as M,s as T,F as j,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as z,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as A,v as I,w as h,x as J,y as P,z as R}from"./index.38300823.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`<h4 class="m-b-xs">Forgotten admin password</h4>
<p>Enter the email associated with your account and we\u2019ll send you a recovery link:</p>`,n=g(),H(l.$$.fragment),t=g(),o=_("button"),f=_("i"),m=g(),i=_("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(i,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=c[1],F(o,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),L(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=A(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(o.disabled=r[1]),$&2&&F(o,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),S(l),b=!1,u()}}}function O(c){let e,s,n,l,t,o,f,m,i;return{c(){e=_("div"),s=_("div"),s.innerHTML='<i class="ri-checkbox-circle-line"></i>',n=g(),l=_("div"),t=_("p"),o=h("Check "),f=_("strong"),m=h(c[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,f),d(f,m),d(t,i)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,o,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",o=c[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),R(t,c[0]),t.focus(),f||(m=A(t,"input",c[4]),f=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&o!==(o=i[5])&&p(t,"id",o),a&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&v(e),i&&v(l),i&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,o,f,m;const i=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=i[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),o=!0,f||(m=z(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=i[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){o||(w(s),o=!0)},o(u){y(s),o=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new j({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){H(e.$$.fragment)},m(n,l){L(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){S(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.Admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.errorResponseHandler(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,o,f]}class Y extends E{constructor(e){super(),M(this,e,W,V,T,{})}}export{Y as default};
@@ -1,4 +1,4 @@
import{S as J,i as M,s as N,F as R,c as S,m as U,t as $,a as v,d as z,C as W,E as Y,g as _,k as j,n as A,o as b,p as F,w as y,e as m,f as d,h as k,x as B,q as D,b as C,r as H,u as E,v as G,y as h,z as T}from"./index.6f2d3c23.js";function I(r){let e,s,t,l,n,o,c,a,i,u,g,q,p=r[3]&&L(r);return o=new D({props:{class:"form-field required",name:"password",$$slots:{default:[O,({uniqueId:f})=>({8:f}),({uniqueId:f})=>f?256:0]},$$scope:{ctx:r}}}),{c(){e=m("form"),s=m("div"),t=m("h4"),l=y(`Type your password to confirm changing your email address
`),p&&p.c(),n=C(),S(o.$$.fragment),c=C(),a=m("button"),i=m("span"),i.textContent="Confirm new email",d(t,"class","m-b-xs"),d(s,"class","content txt-center m-b-sm"),d(i,"class","txt"),d(a,"type","submit"),d(a,"class","btn btn-lg btn-block"),a.disabled=r[1],H(a,"btn-loading",r[1])},m(f,w){_(f,e,w),k(e,s),k(s,t),k(t,l),p&&p.m(t,null),k(e,n),U(o,e,null),k(e,c),k(e,a),k(a,i),u=!0,g||(q=E(e,"submit",G(r[4])),g=!0)},p(f,w){f[3]?p?p.p(f,w):(p=L(f),p.c(),p.m(t,null)):p&&(p.d(1),p=null);const P={};w&769&&(P.$$scope={dirty:w,ctx:f}),o.$set(P),(!u||w&2)&&(a.disabled=f[1]),w&2&&H(a,"btn-loading",f[1])},i(f){u||($(o.$$.fragment,f),u=!0)},o(f){v(o.$$.fragment,f),u=!1},d(f){f&&b(e),p&&p.d(),z(o),g=!1,q()}}}function K(r){let e,s,t,l,n;return{c(){e=m("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
import{S as J,i as M,s as N,F as R,c as S,m as U,t as $,a as v,d as z,C as W,E as Y,g as _,k as j,n as A,o as b,p as F,q as B,e as m,w as y,b as C,f as d,r as H,h as k,u as E,v as D,y as h,x as G,z as T}from"./index.38300823.js";function I(r){let e,s,t,l,n,o,c,a,i,u,g,q,p=r[3]&&L(r);return o=new B({props:{class:"form-field required",name:"password",$$slots:{default:[O,({uniqueId:f})=>({8:f}),({uniqueId:f})=>f?256:0]},$$scope:{ctx:r}}}),{c(){e=m("form"),s=m("div"),t=m("h4"),l=y(`Type your password to confirm changing your email address
`),p&&p.c(),n=C(),S(o.$$.fragment),c=C(),a=m("button"),i=m("span"),i.textContent="Confirm new email",d(t,"class","m-b-xs"),d(s,"class","content txt-center m-b-sm"),d(i,"class","txt"),d(a,"type","submit"),d(a,"class","btn btn-lg btn-block"),a.disabled=r[1],H(a,"btn-loading",r[1])},m(f,w){_(f,e,w),k(e,s),k(s,t),k(t,l),p&&p.m(t,null),k(e,n),U(o,e,null),k(e,c),k(e,a),k(a,i),u=!0,g||(q=E(e,"submit",D(r[4])),g=!0)},p(f,w){f[3]?p?p.p(f,w):(p=L(f),p.c(),p.m(t,null)):p&&(p.d(1),p=null);const P={};w&769&&(P.$$scope={dirty:w,ctx:f}),o.$set(P),(!u||w&2)&&(a.disabled=f[1]),w&2&&H(a,"btn-loading",f[1])},i(f){u||($(o.$$.fragment,f),u=!0)},o(f){v(o.$$.fragment,f),u=!1},d(f){f&&b(e),p&&p.d(),z(o),g=!1,q()}}}function K(r){let e,s,t,l,n;return{c(){e=m("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Email address changed</p>
<p>You can now sign in with your new email address.</p></div>`,s=C(),t=m("button"),t.textContent="Close",d(e,"class","alert alert-success"),d(t,"type","button"),d(t,"class","btn btn-secondary btn-block")},m(o,c){_(o,e,c),_(o,s,c),_(o,t,c),l||(n=E(t,"click",r[6]),l=!0)},p:h,i:h,o:h,d(o){o&&b(e),o&&b(s),o&&b(t),l=!1,n()}}}function L(r){let e,s,t;return{c(){e=y("to "),s=m("strong"),t=y(r[3]),d(s,"class","txt-nowrap")},m(l,n){_(l,e,n),_(l,s,n),k(s,t)},p(l,n){n&8&&B(t,l[3])},d(l){l&&b(e),l&&b(s)}}}function O(r){let e,s,t,l,n,o,c,a;return{c(){e=m("label"),s=y("Password"),l=C(),n=m("input"),d(e,"for",t=r[8]),d(n,"type","password"),d(n,"id",o=r[8]),n.required=!0,n.autofocus=!0},m(i,u){_(i,e,u),k(e,s),_(i,l,u),_(i,n,u),T(n,r[0]),n.focus(),c||(a=E(n,"input",r[7]),c=!0)},p(i,u){u&256&&t!==(t=i[8])&&d(e,"for",t),u&256&&o!==(o=i[8])&&d(n,"id",o),u&1&&n.value!==i[0]&&T(n,i[0])},d(i){i&&b(e),i&&b(l),i&&b(n),c=!1,a()}}}function Q(r){let e,s,t,l;const n=[K,I],o=[];function c(a,i){return a[2]?0:1}return e=c(r),s=o[e]=n[e](r),{c(){s.c(),t=Y()},m(a,i){o[e].m(a,i),_(a,t,i),l=!0},p(a,i){let u=e;e=c(a),e===u?o[e].p(a,i):(j(),v(o[u],1,1,()=>{o[u]=null}),A(),s=o[e],s?s.p(a,i):(s=o[e]=n[e](a),s.c()),$(s,1),s.m(t.parentNode,t))},i(a){l||($(s),l=!0)},o(a){v(s),l=!1},d(a){o[e].d(a),a&&b(t)}}}function V(r){let e,s;return e=new R({props:{nobranding:!0,$$slots:{default:[Q]},$$scope:{ctx:r}}}),{c(){S(e.$$.fragment)},m(t,l){U(e,t,l),s=!0},p(t,[l]){const n={};l&527&&(n.$$scope={dirty:l,ctx:t}),e.$set(n)},i(t){s||($(e.$$.fragment,t),s=!0)},o(t){v(e.$$.fragment,t),s=!1},d(t){z(e,t)}}}function X(r,e,s){let t,{params:l}=e,n="",o=!1,c=!1;async function a(){if(!o){s(1,o=!0);try{await F.Users.confirmEmailChange(l==null?void 0:l.token,n),s(2,c=!0)}catch(g){F.errorResponseHandler(g)}s(1,o=!1)}}const i=()=>window.close();function u(){n=this.value,s(0,n)}return r.$$set=g=>{"params"in g&&s(5,l=g.params)},r.$$.update=()=>{r.$$.dirty&32&&s(3,t=W.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[n,o,c,t,a,l,i,u]}class x extends J{constructor(e){super(),M(this,e,X,V,N,{params:5})}}export{x as default};
<p>You can now sign in with your new email address.</p></div>`,s=C(),t=m("button"),t.textContent="Close",d(e,"class","alert alert-success"),d(t,"type","button"),d(t,"class","btn btn-secondary btn-block")},m(o,c){_(o,e,c),_(o,s,c),_(o,t,c),l||(n=E(t,"click",r[6]),l=!0)},p:h,i:h,o:h,d(o){o&&b(e),o&&b(s),o&&b(t),l=!1,n()}}}function L(r){let e,s,t;return{c(){e=y("to "),s=m("strong"),t=y(r[3]),d(s,"class","txt-nowrap")},m(l,n){_(l,e,n),_(l,s,n),k(s,t)},p(l,n){n&8&&G(t,l[3])},d(l){l&&b(e),l&&b(s)}}}function O(r){let e,s,t,l,n,o,c,a;return{c(){e=m("label"),s=y("Password"),l=C(),n=m("input"),d(e,"for",t=r[8]),d(n,"type","password"),d(n,"id",o=r[8]),n.required=!0,n.autofocus=!0},m(i,u){_(i,e,u),k(e,s),_(i,l,u),_(i,n,u),T(n,r[0]),n.focus(),c||(a=E(n,"input",r[7]),c=!0)},p(i,u){u&256&&t!==(t=i[8])&&d(e,"for",t),u&256&&o!==(o=i[8])&&d(n,"id",o),u&1&&n.value!==i[0]&&T(n,i[0])},d(i){i&&b(e),i&&b(l),i&&b(n),c=!1,a()}}}function Q(r){let e,s,t,l;const n=[K,I],o=[];function c(a,i){return a[2]?0:1}return e=c(r),s=o[e]=n[e](r),{c(){s.c(),t=Y()},m(a,i){o[e].m(a,i),_(a,t,i),l=!0},p(a,i){let u=e;e=c(a),e===u?o[e].p(a,i):(j(),v(o[u],1,1,()=>{o[u]=null}),A(),s=o[e],s?s.p(a,i):(s=o[e]=n[e](a),s.c()),$(s,1),s.m(t.parentNode,t))},i(a){l||($(s),l=!0)},o(a){v(s),l=!1},d(a){o[e].d(a),a&&b(t)}}}function V(r){let e,s;return e=new R({props:{nobranding:!0,$$slots:{default:[Q]},$$scope:{ctx:r}}}),{c(){S(e.$$.fragment)},m(t,l){U(e,t,l),s=!0},p(t,[l]){const n={};l&527&&(n.$$scope={dirty:l,ctx:t}),e.$set(n)},i(t){s||($(e.$$.fragment,t),s=!0)},o(t){v(e.$$.fragment,t),s=!1},d(t){z(e,t)}}}function X(r,e,s){let t,{params:l}=e,n="",o=!1,c=!1;async function a(){if(!o){s(1,o=!0);try{await F.Users.confirmEmailChange(l==null?void 0:l.token,n),s(2,c=!0)}catch(g){F.errorResponseHandler(g)}s(1,o=!1)}}const i=()=>window.close();function u(){n=this.value,s(0,n)}return r.$$set=g=>{"params"in g&&s(5,l=g.params)},r.$$.update=()=>{r.$$.dirty&32&&s(3,t=W.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[n,o,c,t,a,l,i,u]}class x extends J{constructor(e){super(),M(this,e,X,V,N,{params:5})}}export{x as default};
@@ -1,4 +1,4 @@
import{S as W,i as Y,s as j,F as A,c as H,m as N,t as P,a as q,d as S,C as B,E as D,g as _,k as G,n as I,o as m,p as z,w as y,e as b,h as w,x as K,q as E,b as C,f as c,r as J,u as h,v as O,y as F,z as R}from"./index.6f2d3c23.js";function Q(i){let e,l,t,n,s,o,p,a,r,u,v,g,k,L,d=i[4]&&M(i);return o=new E({props:{class:"form-field required",name:"password",$$slots:{default:[X,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),a=new E({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[Z,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),{c(){e=b("form"),l=b("div"),t=b("h4"),n=y(`Reset your user password
`),d&&d.c(),s=C(),H(o.$$.fragment),p=C(),H(a.$$.fragment),r=C(),u=b("button"),v=b("span"),v.textContent="Set new password",c(t,"class","m-b-xs"),c(l,"class","content txt-center m-b-sm"),c(v,"class","txt"),c(u,"type","submit"),c(u,"class","btn btn-lg btn-block"),u.disabled=i[2],J(u,"btn-loading",i[2])},m(f,$){_(f,e,$),w(e,l),w(l,t),w(t,n),d&&d.m(t,null),w(e,s),N(o,e,null),w(e,p),N(a,e,null),w(e,r),w(e,u),w(u,v),g=!0,k||(L=h(e,"submit",O(i[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=M(f),d.c(),d.m(t,null)):d&&(d.d(1),d=null);const T={};$&3073&&(T.$$scope={dirty:$,ctx:f}),o.$set(T);const U={};$&3074&&(U.$$scope={dirty:$,ctx:f}),a.$set(U),(!g||$&4)&&(u.disabled=f[2]),$&4&&J(u,"btn-loading",f[2])},i(f){g||(P(o.$$.fragment,f),P(a.$$.fragment,f),g=!0)},o(f){q(o.$$.fragment,f),q(a.$$.fragment,f),g=!1},d(f){f&&m(e),d&&d.d(),S(o),S(a),k=!1,L()}}}function V(i){let e,l,t,n,s;return{c(){e=b("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
import{S as W,i as Y,s as j,F as A,c as H,m as N,t as P,a as q,d as S,C as B,E as D,g as _,k as G,n as I,o as m,p as z,q as E,e as b,w as y,b as C,f as c,r as J,h as w,u as h,v as K,y as F,x as O,z as R}from"./index.38300823.js";function Q(i){let e,l,t,n,s,o,p,a,r,u,v,g,k,L,d=i[4]&&M(i);return o=new E({props:{class:"form-field required",name:"password",$$slots:{default:[X,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),a=new E({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[Z,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),{c(){e=b("form"),l=b("div"),t=b("h4"),n=y(`Reset your user password
`),d&&d.c(),s=C(),H(o.$$.fragment),p=C(),H(a.$$.fragment),r=C(),u=b("button"),v=b("span"),v.textContent="Set new password",c(t,"class","m-b-xs"),c(l,"class","content txt-center m-b-sm"),c(v,"class","txt"),c(u,"type","submit"),c(u,"class","btn btn-lg btn-block"),u.disabled=i[2],J(u,"btn-loading",i[2])},m(f,$){_(f,e,$),w(e,l),w(l,t),w(t,n),d&&d.m(t,null),w(e,s),N(o,e,null),w(e,p),N(a,e,null),w(e,r),w(e,u),w(u,v),g=!0,k||(L=h(e,"submit",K(i[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=M(f),d.c(),d.m(t,null)):d&&(d.d(1),d=null);const T={};$&3073&&(T.$$scope={dirty:$,ctx:f}),o.$set(T);const U={};$&3074&&(U.$$scope={dirty:$,ctx:f}),a.$set(U),(!g||$&4)&&(u.disabled=f[2]),$&4&&J(u,"btn-loading",f[2])},i(f){g||(P(o.$$.fragment,f),P(a.$$.fragment,f),g=!0)},o(f){q(o.$$.fragment,f),q(a.$$.fragment,f),g=!1},d(f){f&&m(e),d&&d.d(),S(o),S(a),k=!1,L()}}}function V(i){let e,l,t,n,s;return{c(){e=b("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Password changed</p>
<p>You can now sign in with your new password.</p></div>`,l=C(),t=b("button"),t.textContent="Close",c(e,"class","alert alert-success"),c(t,"type","button"),c(t,"class","btn btn-secondary btn-block")},m(o,p){_(o,e,p),_(o,l,p),_(o,t,p),n||(s=h(t,"click",i[7]),n=!0)},p:F,i:F,o:F,d(o){o&&m(e),o&&m(l),o&&m(t),n=!1,s()}}}function M(i){let e,l,t;return{c(){e=y("for "),l=b("strong"),t=y(i[4])},m(n,s){_(n,e,s),_(n,l,s),w(l,t)},p(n,s){s&16&&K(t,n[4])},d(n){n&&m(e),n&&m(l)}}}function X(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0,s.autofocus=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[0]),s.focus(),p||(a=h(s,"input",i[8]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&1&&s.value!==r[0]&&R(s,r[0])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function Z(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password confirm"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[1]),p||(a=h(s,"input",i[9]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&2&&s.value!==r[1]&&R(s,r[1])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function x(i){let e,l,t,n;const s=[V,Q],o=[];function p(a,r){return a[3]?0:1}return e=p(i),l=o[e]=s[e](i),{c(){l.c(),t=D()},m(a,r){o[e].m(a,r),_(a,t,r),n=!0},p(a,r){let u=e;e=p(a),e===u?o[e].p(a,r):(G(),q(o[u],1,1,()=>{o[u]=null}),I(),l=o[e],l?l.p(a,r):(l=o[e]=s[e](a),l.c()),P(l,1),l.m(t.parentNode,t))},i(a){n||(P(l),n=!0)},o(a){q(l),n=!1},d(a){o[e].d(a),a&&m(t)}}}function ee(i){let e,l;return e=new A({props:{nobranding:!0,$$slots:{default:[x]},$$scope:{ctx:i}}}),{c(){H(e.$$.fragment)},m(t,n){N(e,t,n),l=!0},p(t,[n]){const s={};n&2079&&(s.$$scope={dirty:n,ctx:t}),e.$set(s)},i(t){l||(P(e.$$.fragment,t),l=!0)},o(t){q(e.$$.fragment,t),l=!1},d(t){S(e,t)}}}function te(i,e,l){let t,{params:n}=e,s="",o="",p=!1,a=!1;async function r(){if(!p){l(2,p=!0);try{await z.Users.confirmPasswordReset(n==null?void 0:n.token,s,o),l(3,a=!0)}catch(k){z.errorResponseHandler(k)}l(2,p=!1)}}const u=()=>window.close();function v(){s=this.value,l(0,s)}function g(){o=this.value,l(1,o)}return i.$$set=k=>{"params"in k&&l(6,n=k.params)},i.$$.update=()=>{i.$$.dirty&64&&l(4,t=B.getJWTPayload(n==null?void 0:n.token).email||"")},[s,o,p,a,t,r,n,u,v,g]}class le extends W{constructor(e){super(),Y(this,e,te,ee,j,{params:6})}}export{le as default};
<p>You can now sign in with your new password.</p></div>`,l=C(),t=b("button"),t.textContent="Close",c(e,"class","alert alert-success"),c(t,"type","button"),c(t,"class","btn btn-secondary btn-block")},m(o,p){_(o,e,p),_(o,l,p),_(o,t,p),n||(s=h(t,"click",i[7]),n=!0)},p:F,i:F,o:F,d(o){o&&m(e),o&&m(l),o&&m(t),n=!1,s()}}}function M(i){let e,l,t;return{c(){e=y("for "),l=b("strong"),t=y(i[4])},m(n,s){_(n,e,s),_(n,l,s),w(l,t)},p(n,s){s&16&&O(t,n[4])},d(n){n&&m(e),n&&m(l)}}}function X(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0,s.autofocus=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[0]),s.focus(),p||(a=h(s,"input",i[8]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&1&&s.value!==r[0]&&R(s,r[0])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function Z(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password confirm"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[1]),p||(a=h(s,"input",i[9]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&2&&s.value!==r[1]&&R(s,r[1])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function x(i){let e,l,t,n;const s=[V,Q],o=[];function p(a,r){return a[3]?0:1}return e=p(i),l=o[e]=s[e](i),{c(){l.c(),t=D()},m(a,r){o[e].m(a,r),_(a,t,r),n=!0},p(a,r){let u=e;e=p(a),e===u?o[e].p(a,r):(G(),q(o[u],1,1,()=>{o[u]=null}),I(),l=o[e],l?l.p(a,r):(l=o[e]=s[e](a),l.c()),P(l,1),l.m(t.parentNode,t))},i(a){n||(P(l),n=!0)},o(a){q(l),n=!1},d(a){o[e].d(a),a&&m(t)}}}function ee(i){let e,l;return e=new A({props:{nobranding:!0,$$slots:{default:[x]},$$scope:{ctx:i}}}),{c(){H(e.$$.fragment)},m(t,n){N(e,t,n),l=!0},p(t,[n]){const s={};n&2079&&(s.$$scope={dirty:n,ctx:t}),e.$set(s)},i(t){l||(P(e.$$.fragment,t),l=!0)},o(t){q(e.$$.fragment,t),l=!1},d(t){S(e,t)}}}function te(i,e,l){let t,{params:n}=e,s="",o="",p=!1,a=!1;async function r(){if(!p){l(2,p=!0);try{await z.Users.confirmPasswordReset(n==null?void 0:n.token,s,o),l(3,a=!0)}catch(k){z.errorResponseHandler(k)}l(2,p=!1)}}const u=()=>window.close();function v(){s=this.value,l(0,s)}function g(){o=this.value,l(1,o)}return i.$$set=k=>{"params"in k&&l(6,n=k.params)},i.$$.update=()=>{i.$$.dirty&64&&l(4,t=B.getJWTPayload(n==null?void 0:n.token).email||"")},[s,o,p,a,t,r,n,u,v,g]}class le extends W{constructor(e){super(),Y(this,e,te,ee,j,{params:6})}}export{le as default};
@@ -1,3 +1,3 @@
import{S as k,i as v,s as y,F as w,c as x,m as C,t as g,a as $,d as L,p as H,E as M,g as r,o as a,e as u,b as m,f,u as _,y as p}from"./index.6f2d3c23.js";function P(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-error-warning-line"></i></div>
import{S as k,i as v,s as y,F as w,c as x,m as C,t as g,a as $,d as L,p as H,E as M,g as r,o as a,e as u,b as m,f,u as _,y as p}from"./index.38300823.js";function P(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-error-warning-line"></i></div>
<div class="content txt-bold"><p>Invalid or expired verification token.</p></div>`,s=m(),e=u("button"),e.textContent="Close",f(t,"class","alert alert-danger"),f(e,"type","button"),f(e,"class","btn btn-secondary btn-block")},m(l,c){r(l,t,c),r(l,s,c),r(l,e,c),n||(i=_(e,"click",o[4]),n=!0)},p,d(l){l&&a(t),l&&a(s),l&&a(e),n=!1,i()}}}function S(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Successfully verified email address.</p></div>`,s=m(),e=u("button"),e.textContent="Close",f(t,"class","alert alert-success"),f(e,"type","button"),f(e,"class","btn btn-secondary btn-block")},m(l,c){r(l,t,c),r(l,s,c),r(l,e,c),n||(i=_(e,"click",o[3]),n=!0)},p,d(l){l&&a(t),l&&a(s),l&&a(e),n=!1,i()}}}function T(o){let t;return{c(){t=u("div"),t.innerHTML='<div class="loader loader-lg"><em>Please wait...</em></div>',f(t,"class","txt-center")},m(s,e){r(s,t,e)},p,d(s){s&&a(t)}}}function F(o){let t;function s(i,l){return i[1]?T:i[0]?S:P}let e=s(o),n=e(o);return{c(){n.c(),t=M()},m(i,l){n.m(i,l),r(i,t,l)},p(i,l){e===(e=s(i))&&n?n.p(i,l):(n.d(1),n=e(i),n&&(n.c(),n.m(t.parentNode,t)))},d(i){n.d(i),i&&a(t)}}}function U(o){let t,s;return t=new w({props:{nobranding:!0,$$slots:{default:[F]},$$scope:{ctx:o}}}),{c(){x(t.$$.fragment)},m(e,n){C(t,e,n),s=!0},p(e,[n]){const i={};n&67&&(i.$$scope={dirty:n,ctx:e}),t.$set(i)},i(e){s||(g(t.$$.fragment,e),s=!0)},o(e){$(t.$$.fragment,e),s=!1},d(e){L(t,e)}}}function V(o,t,s){let{params:e}=t,n=!1,i=!1;l();async function l(){s(1,i=!0);try{await H.Users.confirmVerification(e==null?void 0:e.token),s(0,n=!0)}catch(d){console.warn(d),s(0,n=!1)}s(1,i=!1)}const c=()=>window.close(),b=()=>window.close();return o.$$set=d=>{"params"in d&&s(2,e=d.params)},[n,i,e,c,b]}class E extends k{constructor(t){super(),v(this,t,V,U,y,{params:2})}}export{E as default};
+382
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-366
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -20,8 +20,8 @@
window.Prism = window.Prism || {};
window.Prism.manual = true;
</script>
<script type="module" crossorigin src="/_/assets/index.6f2d3c23.js"></script>
<link rel="stylesheet" href="/_/assets/index.d97a62dd.css">
<script type="module" crossorigin src="/_/assets/index.38300823.js"></script>
<link rel="stylesheet" href="/_/assets/index.615ff839.css">
</head>
<body>
<div id="app"></div>
+219 -397
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -19,16 +19,16 @@
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.30",
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"chart.js": "^3.7.1",
"chartjs-adapter-luxon": "^1.1.0",
"luxon": "^2.3.2",
"pocketbase": "^0.1.2",
"pocketbase": "^0.2.1",
"prismjs": "^1.28.0",
"sass": "^1.45.0",
"svelte": "^3.44.0",
"svelte-flatpickr": "^3.2.6",
"svelte-spa-router": "^3.2.0",
"vite": "^2.7.0"
"vite": "^3.0.0"
}
}
+25 -1
View File
@@ -10,6 +10,7 @@
import Toasts from "@/components/base/Toasts.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import Confirmation from "@/components/base/Confirmation.svelte";
import { pageTitle, appName } from "@/stores/app";
import { admin } from "@/stores/admin";
import { setErrors } from "@/stores/errors";
import { resetConfirmation } from "@/stores/confirmation";
@@ -18,6 +19,10 @@
let showAppSidebar = false;
$: if ($admin?.id) {
loadAppName();
}
function handleRouteLoading(e) {
if (e?.detail?.location === oldLocation) {
return; // not an actual change
@@ -28,7 +33,7 @@
oldLocation = e?.detail?.location;
// resets
CommonHelper.setDocumentTitle("");
$pageTitle = "";
setErrors({});
resetConfirmation();
}
@@ -37,11 +42,30 @@
replace("/");
}
async function loadAppName() {
if (!$admin?.id) {
return;
}
try {
const settings = await ApiClient.Settings.getAll({
$cancelKey: "loadAppName",
});
$appName = settings?.meta?.appName || "";
} catch (err) {
console.warn("Failed to load app name.", err);
}
}
function logout() {
ApiClient.logout();
}
</script>
<svelte:head>
<title>{CommonHelper.joinNonEmpty([$pageTitle, $appName, "PocketBase"], " - ")}</title>
</svelte:head>
<div class="app-layout">
{#if $admin?.id && showAppSidebar}
<aside class="app-sidebar">
+9 -4
View File
@@ -1,8 +1,10 @@
<script>
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { admin as loggedAdmin } from "@/stores/admin";
import Searchbar from "@/components/base/Searchbar.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import SortHeader from "@/components/base/SortHeader.svelte";
import IdLabel from "@/components/base/IdLabel.svelte";
import FormattedDate from "@/components/base/FormattedDate.svelte";
@@ -11,6 +13,8 @@
const queryParams = CommonHelper.getQueryParams(window.location?.href);
$pageTitle = "Admins";
let adminUpsertPanel;
let admins = [];
let isLoading = false;
@@ -27,10 +31,9 @@
loadAdmins();
}
CommonHelper.setDocumentTitle("Admins");
export function loadAdmins() {
isLoading = true;
admins = []; // reset
return ApiClient.Admins.getFullList(100, {
@@ -42,7 +45,7 @@
isLoading = false;
})
.catch((err) => {
if (err !== null) {
if (!err?.isAbort) {
isLoading = false;
console.warn(err);
clearList();
@@ -62,9 +65,11 @@
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
<div class="breadcrumb-item">Admins</div>
<div class="breadcrumb-item">{$pageTitle}</div>
</nav>
<RefreshButton on:refresh={() => loadAdmins()} />
<div class="flex-fill" />
<button type="button" class="btn btn-expanded" on:click={() => adminUpsertPanel?.show()}>
@@ -1,11 +1,13 @@
<script>
import { onMount } from "svelte";
export let value = "";
export let maxHeight = 200;
let inputElem;
let updateTimeoutId;
$: if (inputElem && typeof value !== undefined) {
$: if (typeof value !== undefined) {
updateInputHeight();
}
@@ -32,6 +34,12 @@
form?.requestSubmit && form.requestSubmit();
}
}
onMount(() => {
updateInputHeight();
return () => clearTimeout(updateTimeoutId);
});
</script>
<textarea bind:this={inputElem} bind:value on:keydown={handleKeydown} {...$$restProps} />
+2
View File
@@ -23,9 +23,11 @@
}
onMount(() => {
container.addEventListener("input", handleChange);
container.addEventListener("change", handleChange);
return () => {
container.removeEventListener("input", handleChange);
container.removeEventListener("change", handleChange);
};
});
@@ -96,6 +96,8 @@
readOnlyCompartment.reconfigure(EditorState.readOnly.of(disabled)),
],
});
triggerNativeChange();
}
$: if (editor && value != editor.state.doc.toString()) {
@@ -228,7 +230,7 @@
return null;
}
let options = [{ label: "null" }, { label: "false" }, { label: "true" }];
let options = [{ label: "false" }, { label: "true" }];
if (!disableIndirectCollectionsKeys) {
options.push({ label: "@collection.*", apply: "@collection." });
+10 -2
View File
@@ -26,11 +26,19 @@
}
</script>
<OverlayPanel bind:this={panel} class="image-preview" popup on:show on:hide>
<OverlayPanel bind:this={panel} class="image-preview" btnClose={false} popup on:show on:hide>
<svelte:fragment slot="header">
<div class="overlay-close" on:click|preventDefault={hide}>
<i class="ri-close-line" />
</div>
</svelte:fragment>
<img src={url} alt="Preview" />
<svelte:fragment slot="footer">
<a href={url} class="link-hint txt-ellipsis">/../{url.substring(url.lastIndexOf("/") + 1)}</a>
<a href={url} title="Download" class="link-hint txt-ellipsis">
/../{url.substring(url.lastIndexOf("/") + 1)}
</a>
<div class="flex-fill" />
<button type="button" class="btn btn-secondary" on:click={hide}>Close</button>
</svelte:fragment>
@@ -0,0 +1,50 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import tooltip from "@/actions/tooltip";
const dispatch = createEventDispatcher();
let tooltipData = { text: "Refresh", position: "right" };
export { tooltipData as tooltip };
let refreshTimeoutId = null;
function refresh() {
dispatch("refresh");
// clear tooltip
const oldTooltipData = tooltipData;
tooltipData = null;
clearTimeout(refreshTimeoutId);
refreshTimeoutId = setTimeout(() => {
refreshTimeoutId = null;
tooltipData = oldTooltipData;
}, 200);
}
onMount(() => {
return () => clearTimeout(refreshTimeoutId);
});
</script>
<button
type="button"
class="btn btn-secondary btn-circle"
class:refreshing={refreshTimeoutId}
use:tooltip={tooltipData}
on:click={refresh}
>
<i class="ri-refresh-line" />
</button>
<style>
@keyframes refresh {
100% {
transform: rotate(180deg);
}
}
.btn.refreshing i {
animation: refresh 200ms ease-out;
}
</style>
@@ -44,16 +44,16 @@
function getSiblingsFieldNames(currentField) {
let result = [];
if (currentField.toDelete) {
return result;
}
for (let field of collection.schema) {
if (field === currentField) {
continue; // skip current
if (field === currentField || field.toDelete) {
continue; // skip current and deleted fields
}
result.push(field.name);
if (field.id && field.originalName !== "" && field.originalName !== field.name) {
result.push(field.originalName);
}
}
return result;
@@ -104,7 +104,7 @@
<p>
Example rule:
<br />
<code>@request.user.id!=null && created>"2022-01-01 00:00:00"</code>
<code>@request.user.id!="" && created>"2022-01-01 00:00:00"</code>
</p>
</div>
</div>
@@ -166,7 +166,7 @@
<OverlayPanel
bind:this={collectionPanel}
class="overlay-panel-lg colored-header collection-panel"
class="overlay-panel-lg colored-header compact-header collection-panel"
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
@@ -5,6 +5,7 @@
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { errors } from "@/stores/errors";
import Toggler from "@/components/base/Toggler.svelte";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import FieldTypeSelect from "@/components/collections/schema/FieldTypeSelect.svelte";
@@ -37,13 +38,6 @@
field.unique = false;
}
$: if (excludeNames.length) {
const normalizedName = normalizeFieldName(field.name);
if (field.name !== normalizedName) {
field.name = normalizedName;
}
}
$: canBeStored = !CommonHelper.isEmpty(field.name) && field.type;
$: if (!canBeStored) {
@@ -54,7 +48,7 @@
accordion && collapse();
// reset the name if it was previously deleted
if (!field.name && field.originalName) {
if (field.originalName && field.name !== field.originalName) {
field.name = field.originalName;
}
}
@@ -67,9 +61,16 @@
field.toDelete = false; // normalize
}
$: if (field.required) {
field.nullable = false;
}
$: interactive = !disabled && !field.system && !field.toDelete && canBeStored;
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
$: hasValidName = validateFieldName(field.name);
$: hasErrors =
!hasValidName || !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
export function expand() {
accordion?.expand();
@@ -88,15 +89,23 @@
}
}
function normalizeFieldName(name) {
name = CommonHelper.slugify(name);
let counter = "";
while (excludeNames.includes(name + counter)) {
++counter;
function validateFieldName(name) {
name = ("" + name).toLowerCase();
if (!name) {
return false;
}
return name + counter;
for (const excluded of excludeNames) {
if (excluded.toLowerCase() === name) {
return false;
}
}
return true;
}
function normalizeFieldName(name) {
return CommonHelper.slugify(name);
}
onMount(() => {
@@ -116,7 +125,7 @@
{interactive}
class={disabled || field.toDelete || field.system ? "field-accordion disabled" : "field-accordion"}
>
<svelte:fragment slot="header" let:active={expanded}>
<svelte:fragment slot="header">
<div class="inline-flex">
<span class="icon field-type">
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
@@ -145,7 +154,7 @@
<div class="flex-fill" />
{#if hasErrors}
{#if hasErrors && !field.system}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
@@ -153,28 +162,6 @@
/>
{/if}
{#if expanded && !field.toDelete}
<div class="inline-flex flex-gap-sm flex-nowrap" in:fly={{ duration: 200, x: 20, opacity: 0 }}>
<button
type="button"
class="btn btn-sm fade p-l-0 p-r-0"
on:click|stopPropagation={handleDelete}
>
<span class="txt">Remove</span>
</button>
{#if interactive}
<button
type="button"
class="btn btn-sm btn-outline btn-expanded-sm"
on:click|stopPropagation={collapse}
>
<span class="txt">Done</span>
</button>
{/if}
</div>
{/if}
{#if field.toDelete}
<button
type="button"
@@ -207,11 +194,23 @@
</div>
<div class="col-sm-6">
<Field
class="form-field required {field.id && field.system ? 'disabled' : ''}"
class="
form-field
required
{!hasValidName ? 'invalid' : ''}
{field.id && field.system ? 'disabled' : ''}
"
name="schema.{key}.name"
let:uniqueId
>
<label for={uniqueId}>Name</label>
<label for={uniqueId}>
<span class="txt">Name</span>
{#if !hasValidName}
<span class="txt invalid-name-note" transition:fly={{ duration: 150, x: 5 }}>
Duplicated or invalid name
</span>
{/if}
</label>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
@@ -255,14 +254,14 @@
{/if}
</div>
<div class="col-4">
<div class="col-sm-4 flex">
<Field class="form-field form-field-toggle m-0" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>Required</label>
</Field>
</div>
<div class="col-4">
<div class="col-sm-4 flex">
{#if field.type !== "file"}
<Field class="form-field form-field-toggle m-0" name="unique" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.unique} />
@@ -270,6 +269,34 @@
</Field>
{/if}
</div>
{#if !field.toDelete}
<div class="col-sm-4 txt-right">
<div class="flex-fill" />
<div class="inline-flex flex-gap-sm flex-nowrap">
<button type="button" class="btn btn-circle btn-sm btn-secondary">
<i class="ri-more-line" />
<Toggler
class="dropdown dropdown-sm dropdown-upside dropdown-right dropdown-nowrap no-min-width"
>
<button type="button" class="dropdown-item txt-right" on:click={handleDelete}>
<span class="txt">Remove</span>
</button>
</Toggler>
</button>
{#if interactive}
<button
type="button"
class="btn btn-sm btn-outline btn-expanded-sm"
on:click|stopPropagation={collapse}
>
<span class="txt">Done</span>
</button>
{/if}
</div>
</div>
{/if}
</div>
<input type="submit" class="hidden" tabindex="-1" />
@@ -277,6 +304,12 @@
</Accordion>
<style>
.invalid-name-note {
position: absolute;
right: 10px;
top: 10px;
text-transform: none;
}
.title.field-name {
max-width: 130px;
overflow: hidden;
@@ -53,14 +53,11 @@
const client = new PocketBase("${ApiClient.baseUrl}");
...
const data = { ... };
client.Records.create("${collection?.name}", data)
.then(function (record) {
// success...
}).catch(function (error) {
// error...
});
const record = await client.Records.create("${collection?.name}", data);
`,
},
];
@@ -161,6 +158,37 @@
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-lg">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand relations when returning the created record. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">
@@ -64,12 +64,9 @@
const client = new PocketBase("${ApiClient.baseUrl}");
client.Records.delete("${collection?.name}", "RECORD_ID")
.then(function () {
// success...
}).catch(function (error) {
// error...
});
...
await client.Records.delete("${collection?.name}", "RECORD_ID");
`,
},
];
@@ -76,20 +76,17 @@
const client = new PocketBase("${ApiClient.baseUrl}");
client.Records.getList("${collection?.name}", { page: 2 })
.then(function (list) {
// success...
}).catch(function (error) {
// error...
});
...
// fetch a paginated records list
const resultList = await client.Records.getList("${collection?.name}", 1, 50, {
filter: "created >= '2022-01-01 00:00:00'",
});
// alternatively you can also fetch all records at once via getFullList:
client.Records.getFullList("${collection?.name}", 200 /* batch size */)
.then(function (records) {
// success...
}).catch(function (error) {
// error...
});
const records = await client.Records.getFullList("${collection?.name}", 200 /* batch size */, {
sort: "-created",
});
`,
},
];
@@ -195,7 +192,7 @@
<span class="label">String</span>
</td>
<td>
Auto expand nested record relations. Ex.:
Auto expand record relations. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
@@ -63,14 +63,11 @@
const client = new PocketBase("${ApiClient.baseUrl}");
...
const data = { ... };
client.Records.update("${collection?.name}", "RECORD_ID", data)
.then(function (record) {
// success...
}).catch(function (error) {
// error...
});
const record = await client.Records.update("${collection?.name}", "RECORD_ID", data);
`,
},
];
@@ -146,7 +143,7 @@
<thead>
<tr>
<th>Param</th>
<th width="60%">Type</th>
<th>Type</th>
<th width="50%">Description</th>
</tr>
</thead>
@@ -191,6 +188,37 @@
</tbody>
</table>
<div class="section-title">Query parameters</div>
<table class="table-compact table-border m-b-lg">
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th width="60%">Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>expand</td>
<td>
<span class="label">String</span>
</td>
<td>
Auto expand relations when returning the updated record. Ex.:
<CodeBlock
content={`
?expand=rel1,rel2.subrel21.subrel22
`}
/>
Supports up to 6-levels depth nested relations expansion. <br />
The expanded relations will be appended to the record under the
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>). Only the
relations that the user has permissions to <strong>view</strong> will be expanded.
</td>
</tr>
</tbody>
</table>
<div class="section-title">Responses</div>
<div class="tabs">
<div class="tabs-header compact left">

Some files were not shown because too many files have changed in this diff Show More