Compare commits

...
58 Commits
Author SHA1 Message Date
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
Gani Georgiev ed741662b2 removed the v prefix from the version command 2022-07-11 10:30:34 +03:00
Gani Georgiev 0c14f32822 added additional info about the @expand query parameter 2022-07-11 10:30:20 +03:00
毛亚琛 ec0d3b0d3d [#69] automatically add version information 2022-07-11 10:26:55 +03:00
wenqingl 10d7faea31 [#62] fixed typo in README
defintions -> definitions
2022-07-11 07:36:38 +03:00
Gani Georgiev f62664098e updated ui/dist 2022-07-10 21:14:38 +03:00
毛亚琛 1dfc314bc1 [#60] fix list api docs example 2022-07-10 21:07:04 +03:00
Gani Georgiev 7b2d88fa30 [#45] don't set Last-Modified header if time.location data cannot be loaded 2022-07-10 20:53:24 +03:00
egor-romanov 72cb2d3f43 [#53] fix migrations register in framework mode 2022-07-10 14:40:51 +03:00
Gani Georgiev 0739e90ff2 [#31] replaced the initial admin create interactive cli with Installer web page 2022-07-10 11:46:21 +03:00
Valley 460c684caa [#47] fixed some doc and code inconsistencies and removed some redundant parentheses 2022-07-10 09:13:44 +03:00
Valley d64fbf9011 [#38] added lint and used the lint suggestions 2022-07-09 17:17:41 +03:00
Gani Georgiev dfd9528847 [#32] changed step value to any 2022-07-09 11:16:35 +03:00
Gani Georgiev d4d425c61d [#6] added note for go1.19 on windows 2022-07-09 11:05:42 +03:00
Gani Georgiev 0f0c00bd03 [#32] allow decimals in the number field 2022-07-09 10:57:21 +03:00
Gani Georgiev a26006ad56 fixed typos 2022-07-09 00:54:48 +03:00
D d761fa12af Fix: Typo in comments (#25) 2022-07-08 23:09:23 +03:00
160 changed files with 2808 additions and 2550 deletions
+5
View File
@@ -29,6 +29,11 @@ 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 GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
+1
View File
@@ -1,4 +1,5 @@
/.vscode/
.idea
.DS_Store
+2
View File
@@ -9,6 +9,8 @@ before:
builds:
- main: ./examples/base
binary: pocketbase
ldflags:
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
+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.
+9
View File
@@ -0,0 +1,9 @@
lint:
golangci-lint run -c ./golangci.yml ./...
test:
go test -v --cover ./...
test-report:
go test -v --cover -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
+8 -4
View File
@@ -19,7 +19,7 @@
**For documentation and examples, please visit https://pocketbase.io/docs.**
> ⚠️ Although the web API defintions are considered stable,
> ⚠️ Although the web API definitions are considered stable,
> please keep in mind that PocketBase is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
@@ -43,6 +43,7 @@ your own custom app specific business logic and still have a single portable exe
# go 1.18+
go get github.com/pocketbase/pocketbase
```
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
### Example
@@ -90,10 +91,12 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.**
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` enviroment variable to `1` or `0` respectively.
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
### Testing
PocketBase comes with mixed bag of unit and integration tests.
@@ -117,8 +120,9 @@ PocketBase is free and open source project licensed under the [MIT License](LICE
You could help continuing its development by:
- [Suggest new features, report issues and fix bugs](https://github.com/pocketbase/pocketbase/issues)
- [Contribute to the source code](CONTRIBUTING.md)
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
- [Donate a small amount](https://pocketbase.io/support-us)
> Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
so all feature requests will be first aligned with what we need for Presentator v3.
+31 -23
View File
@@ -24,7 +24,7 @@ func BindAdminApi(app core.App, rg *echo.Group) {
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth())
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
subGroup.DELETE("/:id", api.delete, RequireAdminAuth())
@@ -66,7 +66,7 @@ func (api *adminApi) refresh(c echo.Context) error {
func (api *adminApi) emailAuth(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
@@ -80,11 +80,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occured while validating the form.", err)
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
@@ -101,7 +101,7 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
@@ -166,7 +166,7 @@ func (api *adminApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
@@ -174,20 +174,24 @@ func (api *adminApi) create(c echo.Context) error {
Admin: admin,
}
handlerErr := api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
// create the admin
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create admin.", err)
}
// create the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnAdminAfterCreateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *adminApi) update(c echo.Context) error {
@@ -205,7 +209,7 @@ func (api *adminApi) update(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
@@ -213,20 +217,24 @@ func (api *adminApi) update(c echo.Context) error {
Admin: admin,
}
handlerErr := api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
// update the admin
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update admin.", err)
}
// update the admin
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
return e.HttpContext.JSON(http.StatusOK, e.Admin)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnAdminAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *adminApi) delete(c echo.Context) error {
+26 -10
View File
@@ -456,12 +456,37 @@ func TestAdminDelete(t *testing.T) {
func TestAdminCreate(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Name: "unauthorized (while having at least 1 existing admin)",
Method: http.MethodPost,
Url: "/api/admins",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "unauthorized (while having 0 existing admins)",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
if err != nil {
t.Fatal(err)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"email":"testnew@example.com"`,
`"avatar":3`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnAdminBeforeCreateRequest": 1,
"OnAdminAfterCreateRequest": 1,
},
},
{
Name: "authorized as user",
Method: http.MethodPost,
@@ -482,9 +507,6 @@ func TestAdminCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeCreateRequest": 1,
},
},
{
Name: "authorized as admin + invalid data format",
@@ -507,9 +529,6 @@ func TestAdminCreate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeCreateRequest": 1,
},
},
{
Name: "authorized as admin + valid data",
@@ -622,9 +641,6 @@ func TestAdminUpdate(t *testing.T) {
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
ExpectedEvents: map[string]int{
"OnAdminBeforeUpdateRequest": 1,
},
},
{
Method: http.MethodPatch,
+81 -10
View File
@@ -15,6 +15,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
// InitApi creates a configured echo instance with registered
@@ -38,13 +39,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
var apiErr *rest.ApiError
switch v := err.(type) {
case (*echo.HTTPError):
case *echo.HTTPError:
if v.Internal != nil && app.IsDebug() {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
apiErr = rest.NewApiError(v.Code, msg, v)
case (*rest.ApiError):
case *rest.ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
@@ -67,17 +68,12 @@ func InitApi(app core.App) (*echo.Echo, error) {
// truly rare case; eg. client already disconnected
if cErr != nil && app.IsDebug() {
log.Println(err)
log.Println(cErr)
}
}
// serves /ui/dist/index.html file
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
e.FileFS("/_", "index.html", ui.DistIndexHTML, middleware.Gzip())
// serves static files from the /ui/dist directory
// (similar to echo.StaticFS but with gzip middleware enabled)
e.GET("/_/*", StaticDirectoryHandler(ui.DistDirFS, false), middleware.Gzip())
// admin ui routes
bindStaticAdminUI(app, e)
// default routes
api := e.Group("/api")
@@ -129,3 +125,78 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.H
return c.FileFS(name, fileSystem)
}
}
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
// serves /ui/dist/index.html file
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
e.FileFS(
"/_",
"index.html",
ui.DistIndexHTML,
middleware.Gzip(),
installerRedirect(app),
)
// serves static files from the /ui/dist directory
// (similar to echo.StaticFS but with gzip middleware enabled)
e.GET(
"/_/*",
StaticDirectoryHandler(ui.DistDirFS, false),
middleware.Gzip(),
)
return nil
}
const totalAdminsCacheKey = "totalAdmins"
func updateTotalAdminsCache(app core.App) error {
total, err := app.Dao().TotalAdmins()
if err != nil {
return err
}
app.Cache().Set(totalAdminsCacheKey, total)
return nil
}
// installerRedirect redirects the user to the installer admin UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App) echo.MiddlewareFunc {
// keep totalAdminsCacheKey value up-to-date
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
return updateTotalAdminsCache(app)
})
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
return updateTotalAdminsCache(app)
})
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
return err
}
}
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
_, hasInstallerParam := c.Request().URL.Query()["installer"]
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "/_/?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/")
}
return next(c)
}
}
}
+28 -44
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",
+2 -1
View File
@@ -1,13 +1,14 @@
package apis_test
import (
"github.com/pocketbase/pocketbase/tests"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"testing"
"github.com/pocketbase/pocketbase/tests"
)
func TestFileDownload(t *testing.T) {
+1 -1
View File
@@ -3,8 +3,8 @@ package apis
import (
"net/http"
"github.com/pocketbase/dbx"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
+25 -3
View File
@@ -77,6 +77,28 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
}
// RequireAdminAuthOnlyIfAny middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
// ONLY if the application has at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
return rest.NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil || totalAdmins == 0 {
return next(c)
}
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
}
}
}
// RequireAdminOrUserAuth middleware requires a request to have
// a valid admin or user Authorization header set
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
@@ -204,11 +226,11 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
if err != nil {
switch v := err.(type) {
case (*echo.HTTPError):
case *echo.HTTPError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
case (*rest.ApiError):
case *rest.ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -259,7 +281,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
// ---
now := time.Now()
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
daysDiff := (now.Sub(lastLogsDeletedAt).Hours() * 24)
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
if daysDiff > float64(app.Settings().Logs.MaxDays) {
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
+119
View File
@@ -291,6 +291,125 @@ func TestRequireAdminAuth(t *testing.T) {
}
}
func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest (while having at least 1 existing admin)",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuthOnlyIfAny(app),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest (while having 0 existing admins)",
Method: http.MethodGet,
Url: "/my/test",
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins
_, err := app.Dao().DB().NewQuery("DELETE FROM {{_admins}}").Execute()
if err != nil {
t.Fatal(err)
}
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuthOnlyIfAny(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuthOnlyIfAny(app),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuthOnlyIfAny(app),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminAuthOnlyIfAny(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireAdminOrUserAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
+24 -20
View File
@@ -9,8 +9,8 @@ import (
"net/http"
"time"
"github.com/pocketbase/dbx"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
@@ -159,22 +159,23 @@ func (api *realtimeApi) bindEvents() {
adminTable := (&models.Admin{}).TableName()
// update user/admin auth state
api.app.OnModelAfterUpdate().Add(func(data *core.ModelEvent) error {
modelTable := data.Model.TableName()
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
modelTable := e.Model.TableName()
var contextKey string
if modelTable == userTable {
switch modelTable {
case userTable:
contextKey = ContextUserKey
} else if modelTable == adminTable {
case adminTable:
contextKey = ContextAdminKey
} else {
default:
return nil
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == data.Model.GetId() {
client.Set(contextKey, data.Model)
if model != nil && model.GetId() == e.Model.GetId() {
client.Set(contextKey, e.Model)
}
}
@@ -182,21 +183,22 @@ func (api *realtimeApi) bindEvents() {
})
// remove user/admin client(s)
api.app.OnModelAfterDelete().Add(func(data *core.ModelEvent) error {
modelTable := data.Model.TableName()
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
modelTable := e.Model.TableName()
var contextKey string
if modelTable == userTable {
switch modelTable {
case userTable:
contextKey = ContextUserKey
} else if modelTable == adminTable {
case adminTable:
contextKey = ContextAdminKey
} else {
default:
return nil
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == data.Model.GetId() {
if model != nil && model.GetId() == e.Model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
@@ -204,18 +206,20 @@ func (api *realtimeApi) bindEvents() {
return nil
})
api.app.OnRecordAfterCreateRequest().Add(func(data *core.RecordCreateEvent) error {
api.broadcastRecord("create", data.Record)
api.app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
api.broadcastRecord("create", e.Record)
return nil
})
api.app.OnRecordAfterUpdateRequest().Add(func(data *core.RecordUpdateEvent) error {
api.broadcastRecord("update", data.Record)
api.app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
api.broadcastRecord("update", e.Record)
return nil
})
api.app.OnRecordAfterDeleteRequest().Add(func(data *core.RecordDeleteEvent) error {
api.broadcastRecord("delete", data.Record)
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("delete", record)
}
return nil
})
}
+28 -42
View File
@@ -6,8 +6,8 @@ import (
"net/http"
"strings"
"github.com/pocketbase/dbx"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
@@ -193,7 +193,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 +210,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 +218,24 @@ 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)
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 +280,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 +288,24 @@ 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)
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 +356,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 +366,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{}
+27 -16
View File
@@ -144,12 +144,11 @@ func TestRecordsList(t *testing.T) {
`"manyrels":[{`,
`"manyrels":[]`,
`"rel_cascade":"`,
`"rel_cascade":null`,
`"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"`,
@@ -455,11 +454,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, // +1 cascade delete related record
"OnModelAfterDelete": 3, // +1 cascade delete related record
},
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 +476,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, // +1 cascade delete related record
"OnModelAfterDelete": 3, // +1 cascade delete related record
},
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 +557,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 +568,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")
},
},
@@ -650,7 +654,8 @@ func TestRecordCreate(t *testing.T) {
"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
@@ -668,7 +673,8 @@ func TestRecordCreate(t *testing.T) {
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text":"test123",
"bool":true
"bool":true,
"number":1
}`),
RequestHeaders: map[string]string{
// test3@example.com
@@ -682,6 +688,7 @@ func TestRecordCreate(t *testing.T) {
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"text":"test123"`,
`"bool":true`,
`"number":1`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
@@ -695,11 +702,12 @@ func TestRecordCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/collections/demo2/records",
Body: strings.NewReader(`{
"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text":"test123",
"bool":false
"rel_cascade": "577bd676-aacb-4072-b7da-99d00ee210a4",
"onerel": "577bd676-aacb-4072-b7da-99d00ee210a4",
"manyrels" :["577bd676-aacb-4072-b7da-99d00ee210a4"],
"text": "test123",
"bool": false,
"number": 1
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
@@ -712,6 +720,7 @@ func TestRecordCreate(t *testing.T) {
`"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`,
`"text":"test123"`,
`"bool":false`,
`"number":1`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
@@ -867,7 +876,8 @@ func TestRecordUpdate(t *testing.T) {
Method: http.MethodPatch,
Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f",
Body: strings.NewReader(`{
"text":"test_new"
"text":"test_new",
"number":1
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
@@ -876,6 +886,7 @@ func TestRecordUpdate(t *testing.T) {
ExpectedContent: []string{
`"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`,
`"text":"test_new"`,
`"number":1`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeUpdateRequest": 1,
+19 -12
View File
@@ -40,8 +40,10 @@ func (api *settingsApi) list(c echo.Context) error {
func (api *settingsApi) set(c echo.Context) error {
form := forms.NewSettingsUpsert(api.app)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
@@ -50,22 +52,27 @@ func (api *settingsApi) set(c echo.Context) error {
NewSettings: form.Settings,
}
handlerErr := api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("An error occured while submitting the form.", err)
}
// update the settings
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
-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",
+38 -30
View File
@@ -152,12 +152,12 @@ func (api *userApi) authMethods(c echo.Context) error {
func (api *userApi) oauth2Auth(c echo.Context) error {
form := forms.NewUserOauth2Login(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, authData, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticated.", submitErr)
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, user, authData)
@@ -170,7 +170,7 @@ func (api *userApi) emailAuth(c echo.Context) error {
form := forms.NewUserEmailLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
@@ -184,11 +184,11 @@ func (api *userApi) emailAuth(c echo.Context) error {
func (api *userApi) requestPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occured while validating the form.", err)
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
@@ -205,7 +205,7 @@ func (api *userApi) requestPasswordReset(c echo.Context) error {
func (api *userApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
@@ -224,7 +224,7 @@ func (api *userApi) requestEmailChange(c echo.Context) error {
form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Submit(); err != nil {
@@ -237,7 +237,7 @@ func (api *userApi) requestEmailChange(c echo.Context) error {
func (api *userApi) confirmEmailChange(c echo.Context) error {
form := forms.NewUserEmailChangeConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
@@ -251,11 +251,11 @@ func (api *userApi) confirmEmailChange(c echo.Context) error {
func (api *userApi) requestVerification(c echo.Context) error {
form := forms.NewUserVerificationRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", err)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occured while validating the form.", err)
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
@@ -272,12 +272,12 @@ func (api *userApi) requestVerification(c echo.Context) error {
func (api *userApi) confirmVerification(c echo.Context) error {
form := forms.NewUserVerificationConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr)
return rest.NewBadRequestError("An error occurred while reading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("An error occured while submitting the form.", submitErr)
return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
}
return api.authResponse(c, user, nil)
@@ -348,7 +348,7 @@ func (api *userApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.UserCreateEvent{
@@ -356,20 +356,24 @@ func (api *userApi) create(c echo.Context) error {
User: user,
}
handlerErr := api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
// create the user
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to create user.", err)
}
// create the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnUserAfterCreateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *userApi) update(c echo.Context) error {
@@ -387,7 +391,7 @@ func (api *userApi) update(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err)
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.UserUpdateEvent{
@@ -395,20 +399,24 @@ func (api *userApi) update(c echo.Context) error {
User: user,
}
handlerErr := api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
// update the user
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to update user.", err)
}
// update the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if handlerErr == nil {
if submitErr == nil {
api.app.OnUserAfterUpdateRequest().Trigger(event)
}
return handlerErr
return submitErr
}
func (api *userApi) delete(c echo.Context) error {
-9
View File
@@ -748,9 +748,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 +761,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 +862,6 @@ func TestUserUpdate(t *testing.T) {
`"data":{`,
`"email":{"code":"validation_user_email_exists"`,
},
ExpectedEvents: map[string]int{
"OnUserBeforeUpdateRequest": 1,
},
},
{
Name: "authorized as admin - valid data",
+10 -83
View File
@@ -2,7 +2,6 @@ package cmd
import (
"crypto/tls"
"errors"
"fmt"
"log"
"net"
@@ -10,14 +9,10 @@ import (
"path/filepath"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
"golang.org/x/crypto/acme"
@@ -35,18 +30,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
Use: "serve",
Short: "Starts the web server (default to localhost:8090)",
Run: func(command *cobra.Command, args []string) {
router, err := apis.InitApi(app)
if err != nil {
panic(err)
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: allowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
})))
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
panic(err)
@@ -61,19 +44,18 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
color.Yellow("=====================================")
}
// if no admins are found, create the first one
totalAdmins, err := app.Dao().TotalAdmins()
router, err := apis.InitApi(app)
if err != nil {
log.Fatalln(err)
return
}
if totalAdmins == 0 {
if err := promptCreateAdmin(app); err != nil {
log.Fatalln(err)
return
}
panic(err)
}
// configure cors
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
Skipper: middleware.DefaultSkipper,
AllowOrigins: allowedOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
}))
// start http server
// ---
mainAddr := httpAddr
@@ -149,7 +131,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
&httpsAddr,
"https",
"",
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incomming --http address traffic also will be redirected to this address",
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
)
return command
@@ -171,58 +153,3 @@ func runMigrations(app core.App) error {
return nil
}
func promptCreateAdmin(app core.App) error {
color.White("-------------------------------------")
color.Cyan("Lets create your first admin account:")
color.White("-------------------------------------")
prompts := []*survey.Question{
{
Name: "Email",
Prompt: &survey.Input{Message: "Email:"},
Validate: func(val any) error {
if err := survey.Required(val); err != nil {
return err
}
if err := is.Email.Validate(val); err != nil {
return err
}
return nil
},
},
{
Name: "Password",
Prompt: &survey.Password{Message: "Pass (min 10 chars):"},
Validate: func(val any) error {
if str, ok := val.(string); !ok || len(str) < 10 {
return errors.New("The password must be at least 10 characters.")
}
return nil
},
},
}
result := struct {
Email string
Password string
}{}
if err := survey.Ask(prompts, &result); err != nil {
return err
}
form := forms.NewAdminUpsert(app, &models.Admin{})
form.Email = result.Email
form.Password = result.Password
form.PasswordConfirm = result.Password
if err := form.Submit(); err != nil {
return err
}
color.Green("Successfully created admin %s!", result.Email)
fmt.Println("")
return nil
}
-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 v%s\n", version)
},
}
}
+8 -8
View File
@@ -91,7 +91,7 @@ type App interface {
// entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeCreate() *hook.Hook[*ModelEvent]
// OnModelAfterCreate hook is triggered after successfuly
// OnModelAfterCreate hook is triggered after successfully
// inserting a new entry in the DB.
OnModelAfterCreate() *hook.Hook[*ModelEvent]
@@ -99,7 +99,7 @@ type App interface {
// entry in the DB, allowing you to modify or validate the stored data.
OnModelBeforeUpdate() *hook.Hook[*ModelEvent]
// OnModelAfterUpdate hook is triggered after successfuly updating
// OnModelAfterUpdate hook is triggered after successfully updating
// existing entry in the DB.
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
@@ -107,7 +107,7 @@ type App interface {
// existing entry from the DB.
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
// OnModelAfterDelete is triggered after successfuly deleting an
// OnModelAfterDelete is triggered after successfully deleting an
// existing entry from the DB.
OnModelAfterDelete() *hook.Hook[*ModelEvent]
@@ -123,7 +123,7 @@ type App interface {
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
// admin password reset email was successfuly sent.
// admin password reset email was successfully sent.
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
@@ -134,7 +134,7 @@ type App interface {
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserResetPasswordSend hook is triggered after
// a user password reset email was successfuly sent.
// a user password reset email was successfully sent.
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// OnMailerBeforeUserVerificationSend hook is triggered right before
@@ -145,7 +145,7 @@ type App interface {
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserVerificationSend hook is triggered after a user
// verification email was successfuly sent.
// verification email was successfully sent.
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
@@ -156,7 +156,7 @@ type App interface {
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
// change address email was successfuly sent.
// change address email was successfully sent.
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// ---------------------------------------------------------------
@@ -180,7 +180,7 @@ type App interface {
// Settings API event hooks
// ---------------------------------------------------------------
// OnSettingsListRequest hook is triggered on each successfull
// OnSettingsListRequest hook is triggered on each successful
// API Settings list request.
//
// Could be used to validate or modify the response before
+36 -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
@@ -750,3 +755,33 @@ func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao {
return dao
}
func (app *BaseApp) registerDefaultHooks() {
deletePrefix := func(prefix string) error {
fs, err := app.NewFilesystem()
if err != nil {
return err
}
defer fs.Close()
failed := fs.DeletePrefix(prefix)
if len(failed) > 0 {
return errors.New("Failed to delete the files at " + prefix)
}
return nil
}
// delete storage files from deleted Collection, Records, etc.
app.OnModelAfterDelete().Add(func(e *ModelEvent) error {
if m, ok := e.Model.(models.FilesManager); ok && m.BaseFilesPath() != "" {
if err := deletePrefix(m.BaseFilesPath()); err != nil && app.IsDebug() {
// non critical error - only log for debug
// (usually could happen because of S3 api limits)
log.Println(err)
}
}
return nil
})
}
+1 -1
View File
@@ -5,8 +5,8 @@ package core
import (
"fmt"
"github.com/pocketbase/dbx"
_ "github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
)
func connectDB(dbPath string) (*dbx.DB, error) {
+7 -7
View File
@@ -148,12 +148,12 @@ func (s *Settings) Merge(other *Settings) error {
}
// Clone creates a new deep copy of the current settings.
func (c *Settings) Clone() (*Settings, error) {
new := &Settings{}
if err := new.Merge(c); err != nil {
func (s *Settings) Clone() (*Settings, error) {
settings := &Settings{}
if err := settings.Merge(s); err != nil {
return nil, err
}
return new, nil
return settings, nil
}
// RedactClone creates a new deep copy of the current settings,
@@ -255,7 +255,7 @@ type S3Config struct {
// Validate makes S3Config validatable by implementing [validation.Validatable] interface.
func (c S3Config) Validate() error {
return validation.ValidateStruct(&c,
validation.Field(&c.Endpoint, is.Host, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Endpoint, is.URL, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Bucket, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.Region, validation.When(c.Enabled, validation.Required)),
validation.Field(&c.AccessKey, validation.When(c.Enabled, validation.Required)),
@@ -360,11 +360,11 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
}
if c.ClientId != "" {
provider.SetClientId(string(c.ClientId))
provider.SetClientId(c.ClientId)
}
if c.ClientSecret != "" {
provider.SetClientSecret(string(c.ClientSecret))
provider.SetClientSecret(c.ClientSecret)
}
if c.AuthUrl != "" {
+13 -1
View File
@@ -315,7 +315,19 @@ func TestS3ConfigValidate(t *testing.T) {
},
true,
},
// valid data
// valid data (url endpoint)
{
core.S3Config{
Enabled: true,
Endpoint: "https://localhost:8090",
Bucket: "test",
Region: "test",
AccessKey: "test",
Secret: "test",
},
false,
},
// valid data (hostname endpoint)
{
core.S3Config{
Enabled: true,
+1 -1
View File
@@ -45,7 +45,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
return model, nil
}
// FindAdminByEmail finds the admin associated with the provided JWT token.
// FindAdminByToken finds the admin associated with the provided JWT token.
//
// Returns an error if the JWT token is invalid or expired.
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
+4 -7
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
View File
@@ -166,7 +166,6 @@ func TestDeleteCollection(t *testing.T) {
t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr)
}
}
}
func TestSaveCollectionCreate(t *testing.T) {
+1 -3
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{}
+29 -7
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"
)
@@ -309,7 +310,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
newSchema := newCollection.Schema
// check for renamed table
if strings.ToLower(oldTableName) != strings.ToLower(newTableName) {
if !strings.EqualFold(oldTableName, newTableName) {
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
if err != nil {
return err
@@ -329,23 +330,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
})
}
+8 -10
View File
@@ -64,7 +64,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 +92,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 +120,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 {
+3
View File
@@ -396,6 +396,9 @@ func TestSyncRecordTableSchema(t *testing.T) {
t.Fatal(err)
}
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
if err != nil {
t.Fatal(err)
}
updatedCollection.Name = "demo_renamed"
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
updatedCollection.Schema.AddField(
+3 -3
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
}
+1 -1
View File
@@ -107,7 +107,7 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
}
claims, _ := security.ParseUnverifiedJWT(s.token)
tokenAdminId, _ := claims["id"]
tokenAdminId := claims["id"]
if admin.Id != tokenAdminId {
t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin)
+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()
}
+8 -18
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)),
@@ -95,7 +94,7 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
if (form.isCreate || strings.ToLower(v) != strings.ToLower(form.collection.Name)) && form.app.Dao().HasTable(v) {
if (form.isCreate || !strings.EqualFold(v, form.collection.Name)) && form.app.Dao().HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
@@ -154,20 +153,6 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
return nil
}
func (form *CollectionUpsert) ensureNoFieldsNameReuse(value any) error {
v, _ := value.(schema.Schema)
for _, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldByName(field.Name)
if oldField != nil && oldField.Id != field.Id {
return validation.NewError("validation_field_old_field_exist", "Cannot use existing schema field names when renaming fields.")
}
}
return nil
}
func (form *CollectionUpsert) checkRule(value any) error {
v, _ := value.(*string)
@@ -189,7 +174,10 @@ func (form *CollectionUpsert) checkRule(value any) error {
// Submit validates the form and upserts the form's Collection model.
//
// On success the related record table schema will be auto updated.
func (form *CollectionUpsert) Submit() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
@@ -211,5 +199,7 @@ func (form *CollectionUpsert) Submit() error {
form.collection.UpdateRule = form.UpdateRule
form.collection.DeleteRule = form.DeleteRule
return form.app.Dao().SaveCollection(form.collection)
return runInterceptors(func() error {
return form.app.Dao().SaveCollection(form.collection)
}, interceptors...)
}
+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")
}
}
+99 -83
View File
@@ -3,6 +3,7 @@ package forms
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
@@ -44,7 +45,6 @@ func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
form.Data = map[string]any{}
for _, field := range record.Collection().Schema.Fields() {
form.Data[field.Name] = record.GetDataValue(field.Name)
}
return form
@@ -147,79 +147,80 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
for _, field := range form.record.Collection().Schema.Fields() {
key := field.Name
value, _ := extendedData[key]
value := extendedData[key]
value = field.PrepareValue(value)
if field.Type == schema.FieldTypeFile {
options, _ := field.Options.(*schema.FileOptions)
oldNames := list.ToUniqueStringSlice(form.Data[key])
if field.Type != schema.FieldTypeFile {
form.Data[key] = value
continue
}
// delete previously uploaded file(s)
if options.MaxSelect == 1 {
// search for unset zero indexed key as a fallback
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
options, _ := field.Options.(*schema.FileOptions)
oldNames := list.ToUniqueStringSlice(form.Data[key])
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
if len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
}
form.Data[key] = nil
// delete previously uploaded file(s)
if options.MaxSelect == 1 {
// search for unset zero indexed key as a fallback
indexedKeyValue, hasIndexedKey := extendedData[key+".0"]
if cast.ToString(value) == "" || (hasIndexedKey && cast.ToString(indexedKeyValue) == "") {
if len(oldNames) > 0 {
form.filesToDelete = append(form.filesToDelete, oldNames...)
}
} else if options.MaxSelect > 1 {
// search for individual file index to delete (eg. "file.0")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
indexesToDelete := []int{}
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
indexesToDelete = append(indexesToDelete, index)
}
}
// slice to fill only with the non-deleted indexes
nonDeleted := []string{}
for i, name := range oldNames {
// not marked for deletion
if !list.ExistInSlice(i, indexesToDelete) {
nonDeleted = append(nonDeleted, name)
form.Data[key] = ""
}
} else if options.MaxSelect > 1 {
// search for individual file index to delete (eg. "file.0")
keyExp, _ := regexp.Compile(`^` + regexp.QuoteMeta(key) + `\.\d+$`)
indexesToDelete := make([]int, 0, len(extendedData))
for indexedKey := range extendedData {
if keyExp.MatchString(indexedKey) && cast.ToString(extendedData[indexedKey]) == "" {
index, indexErr := strconv.Atoi(indexedKey[len(key)+1:])
if indexErr != nil || index >= len(oldNames) {
continue
}
// store the id to actually delete the file later
form.filesToDelete = append(form.filesToDelete, name)
indexesToDelete = append(indexesToDelete, index)
}
form.Data[key] = nonDeleted
}
// check if there are any new uploaded form files
files, err := rest.FindUploadedFiles(r, key)
if err != nil {
continue // skip invalid or missing file(s)
}
// refresh oldNames list
oldNames = list.ToUniqueStringSlice(form.Data[key])
if options.MaxSelect == 1 {
// delete previous file(s) before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
// slice to fill only with the non-deleted indexes
nonDeleted := make([]string, 0, len(oldNames))
for i, name := range oldNames {
// not marked for deletion
if !list.ExistInSlice(i, indexesToDelete) {
nonDeleted = append(nonDeleted, name)
continue
}
form.filesToUpload = append(form.filesToUpload, files[0])
form.Data[key] = files[0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
form.filesToUpload = append(form.filesToUpload, files...)
for _, file := range files {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
// store the id to actually delete the file later
form.filesToDelete = append(form.filesToDelete, name)
}
} else {
form.Data[key] = value
form.Data[key] = nonDeleted
}
// check if there are any new uploaded form files
files, err := rest.FindUploadedFiles(r, key)
if err != nil {
continue // skip invalid or missing file(s)
}
// refresh oldNames list
oldNames = list.ToUniqueStringSlice(form.Data[key])
if options.MaxSelect == 1 {
// delete previous file(s) before replacing
if len(oldNames) > 0 {
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
}
form.filesToUpload = append(form.filesToUpload, files[0])
form.Data[key] = files[0].Name()
} else if options.MaxSelect > 1 {
// append the id of each uploaded file instance
form.filesToUpload = append(form.filesToUpload, files...)
for _, file := range files {
oldNames = append(oldNames, file.Name())
}
form.Data[key] = oldNames
}
}
@@ -271,7 +272,10 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
}
// Submit validates the form and upserts the form Record model.
func (form *RecordUpsert) Submit() error {
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
@@ -281,25 +285,27 @@ func (form *RecordUpsert) Submit() error {
return err
}
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return err
}
return runInterceptors(func() error {
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return err
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return err
}
// upload new files (if any)
if err := form.processFilesToUpload(); err != nil {
return err
}
// delete old files (if any)
if err := form.processFilesToDelete(); err != nil {
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
}
// delete old files (if any)
if err := form.processFilesToDelete(); err != nil { //nolint:staticcheck
// for now fail silently to avoid reupload when `form.Submit()`
// is called manually (aka. not from an api request)...
}
return nil
})
return nil
})
}, interceptors...)
}
func (form *RecordUpsert) processFilesToUpload() error {
@@ -317,17 +323,22 @@ func (form *RecordUpsert) processFilesToUpload() error {
}
defer fs.Close()
var uploadErrors []error
for i := len(form.filesToUpload) - 1; i >= 0; i-- {
file := form.filesToUpload[i]
path := form.record.BaseFilesPath() + "/" + file.Name()
if err := fs.Upload(file.Bytes(), path); err == nil {
// remove the uploaded file from the list
form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
} else {
// store the upload error
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
}
}
if len(form.filesToUpload) > 0 {
return errors.New("Failed to upload all files.")
if len(uploadErrors) > 0 {
return fmt.Errorf("Failed to upload all files: %v", uploadErrors)
}
return nil
@@ -348,20 +359,25 @@ func (form *RecordUpsert) processFilesToDelete() error {
}
defer fs.Close()
var deleteErrors []error
for i := len(form.filesToDelete) - 1; i >= 0; i-- {
filename := form.filesToDelete[i]
path := form.record.BaseFilesPath() + "/" + filename
if err := fs.Delete(path); err == nil {
// remove the deleted file from the list
form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...)
} else {
// store the delete error
deleteErrors = append(deleteErrors, fmt.Errorf("File %d: %v", i, err))
}
// try to delete the related file thumbs (if any)
fs.DeletePrefix(form.record.BaseFilesPath() + "/thumbs_" + filename + "/")
}
if len(form.filesToDelete) > 0 {
return errors.New("Failed to delete all files.")
if len(deleteErrors) > 0 {
return fmt.Errorf("Failed to delete all files: %v", deleteErrors)
}
return nil
+87 -7
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -29,7 +30,7 @@ func TestNewRecordUpsert(t *testing.T) {
form := forms.NewRecordUpsert(app, record)
val, _ := form.Data["title"]
val := form.Data["title"]
if val != "test_value" {
t.Errorf("Expected record data to be load, got %v", form.Data)
}
@@ -97,8 +98,8 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
if !ok {
t.Fatal("Expect onefile field to be set")
}
if onefile != nil {
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
if onefile != "" {
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
}
manyfiles, ok := form.Data["manyfiles"]
@@ -165,8 +166,8 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
if !ok {
t.Fatal("Expect onefile field to be set")
}
if onefile != nil {
t.Fatalf("Expect onefile field to be nil, got %v", onefile)
if onefile != "" {
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
}
manyfiles, ok := form.Data["manyfiles"]
@@ -400,13 +401,27 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
form.LoadData(req)
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorCalls++
return next()
}
}
// ensure that validate is triggered
// ---
result := form.Submit()
result := form.Submit(interceptor)
if result == nil {
t.Fatal("Expected error, got nil")
}
// check interceptor calls
// ---
if interceptorCalls != 0 {
t.Fatalf("Expected interceptor to be called 0 times, got %d", interceptorCalls)
}
// ensure that the record changes weren't persisted
// ---
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
@@ -451,11 +466,25 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
form.LoadData(req)
result := form.Submit()
interceptorCalls := 0
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorCalls++
return next()
}
}
result := form.Submit(interceptor)
if result != nil {
t.Fatalf("Expected nil, got error %v", result)
}
// check interceptor calls
// ---
if interceptorCalls != 1 {
t.Fatalf("Expected interceptor to be called 1 time, got %d", interceptorCalls)
}
// ensure that the record changes were persisted
// ---
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
@@ -482,6 +511,57 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
}
}
func TestRecordUpsertSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, record)
form.Data["title"] = "test_new"
testErr := errors.New("test_error")
interceptorRecordTitle := ""
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptorRecordTitle = record.GetStringDataValue("title") // to check if the record was filled
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
if interceptorRecordTitle != form.Data["title"].(string) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
fs, _ := app.NewFilesystem()
defer fs.Close()
+20 -15
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")
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/pocketbase/pocketbase/models"
)
// UserEmailChangeConfirm defines a user email change request form.
// UserEmailChangeRequest defines a user email change request form.
type UserEmailChangeRequest struct {
app core.App
user *models.User
+26 -25
View File
@@ -68,7 +68,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, nil, err
}
config, _ := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
config := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
config.SetupProvider(provider)
provider.SetRedirectUrl(form.RedirectUrl)
@@ -98,35 +98,36 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, authData, err
}
}
} else {
if !config.AllowRegistrations {
// registration of new users is not allowed via the Oauth2 provider
return nil, authData, errors.New("Cannot find user with the authorized email.")
}
return user, authData, nil
}
// create new user
user = &models.User{Verified: true}
upsertForm := NewUserUpsert(form.app, user)
upsertForm.Email = authData.Email
upsertForm.Password = security.RandomString(30)
upsertForm.PasswordConfirm = upsertForm.Password
if !config.AllowRegistrations {
// registration of new users is not allowed via the Oauth2 provider
return nil, authData, errors.New("Cannot find user with the authorized email.")
}
event := &core.UserOauth2RegisterEvent{
User: user,
AuthData: authData,
}
// create new user
user = &models.User{Verified: true}
upsertForm := NewUserUpsert(form.app, user)
upsertForm.Email = authData.Email
upsertForm.Password = security.RandomString(30)
upsertForm.PasswordConfirm = upsertForm.Password
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
event := &core.UserOauth2RegisterEvent{
User: user,
AuthData: authData,
}
if err := upsertForm.Submit(); err != nil {
return nil, authData, err
}
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
if err := upsertForm.Submit(); err != nil {
return nil, authData, err
}
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
return user, authData, nil
+1 -1
View File
@@ -148,7 +148,7 @@ func TestUserPasswordResetConfirmSubmit(t *testing.T) {
}
claims, _ := security.ParseUnverifiedJWT(form.Token)
tokenUserId, _ := claims["id"]
tokenUserId := claims["id"]
if user.Id != tokenUserId {
t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user)
+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")
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ func TestUserVerificationConfirmSubmit(t *testing.T) {
}
claims, _ := security.ParseUnverifiedJWT(form.Token)
tokenUserId, _ := claims["id"]
tokenUserId := claims["id"]
if user.Id != tokenUserId {
t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id)
+19 -33
View File
@@ -6,9 +6,9 @@ import (
"regexp"
"strings"
"github.com/pocketbase/dbx"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
@@ -240,7 +240,6 @@ func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, v
func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error {
val, _ := value.(types.DateTime)
if val.IsZero() {
if field.Required {
return requiredErr
@@ -268,6 +267,9 @@ func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField,
func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error {
normalizedVal := list.ToUniqueStringSlice(value)
if len(normalizedVal) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
@@ -302,13 +304,9 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
}
func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField, value any) error {
// normalize value access
var names []string
switch v := value.(type) {
case []string:
names = v
case string:
names = []string{v}
names := list.ToUniqueStringSlice(value)
if len(names) == 0 && field.Required {
return requiredErr
}
options, _ := field.Options.(*schema.FileOptions)
@@ -318,12 +316,10 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
// extract the uploaded files
files := []*rest.UploadedFile{}
if len(validator.uploadedFiles) > 0 {
for _, file := range validator.uploadedFiles {
if list.ExistInSlice(file.Name(), names) {
files = append(files, file)
}
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles))
for _, file := range validator.uploadedFiles {
if list.ExistInSlice(file.Name(), names) {
files = append(files, file)
}
}
@@ -345,16 +341,11 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaField, value any) error {
// normalize value access
var ids []string
switch v := value.(type) {
case []string:
ids = v
case string:
ids = []string{v}
}
ids := list.ToUniqueStringSlice(value)
if len(ids) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
@@ -385,16 +376,11 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
}
func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
// normalize value access
var ids []string
switch v := value.(type) {
case []string:
ids = v
case string:
ids = []string{v}
}
ids := list.ToUniqueStringSlice(value)
if len(ids) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
+63 -63
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,
@@ -1189,7 +1189,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: 3,
CollectionId: "", // missing or nonexisting colleciton id
CollectionId: "", // missing or non-existing collection id
},
},
)
+30
View File
@@ -0,0 +1,30 @@
run:
go: 1.18
concurrency: 4
timeout: 10m
linters:
disable-all: true
enable:
- asciicheck
- deadcode
- depguard
- exportloopref
- gocritic
- gofmt
- goimports
- gomodguard
- goprintffuncname
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nolintlint
- prealloc
- staticcheck
- typecheck
- unconvert
- unused
- varcheck
- whitespace
+10 -1
View File
@@ -3,6 +3,8 @@ package migrations
import (
"fmt"
"path/filepath"
"runtime"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
@@ -20,7 +22,14 @@ func Register(
down func(db dbx.Builder) error,
optFilename ...string,
) {
AppMigrations.Register(up, down, optFilename...)
var optFiles []string
if len(optFilename) > 0 {
optFiles = optFilename
} else {
_, path, _, _ := runtime.Caller(1)
optFiles = append(optFiles, filepath.Base(path))
}
AppMigrations.Register(up, down, optFiles...)
}
func init() {
+8 -2
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
@@ -53,7 +59,7 @@ func (m *BaseModel) GetCreated() types.DateTime {
return m.Created
}
// GetCreated returns the model's Updated datetime.
// GetUpdated returns the model's Updated datetime.
func (m *BaseModel) GetUpdated() types.DateTime {
return m.Updated
}
@@ -71,7 +77,7 @@ func (m *BaseModel) RefreshCreated() {
m.Created = types.NowDateTime()
}
// RefreshCreated updates the model's Created field with the current datetime.
// RefreshUpdated updates the model's Updated field with the current datetime.
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
+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
+6 -5
View File
@@ -16,6 +16,7 @@ import (
var _ Model = (*Record)(nil)
var _ ColumnValueMapper = (*Record)(nil)
var _ FilesManager = (*Record)(nil)
type Record struct {
BaseModel
@@ -68,21 +69,21 @@ func NewRecordFromNullStringMap(collection *Collection, data dbx.NullStringMap)
// NewRecordsFromNullStringMaps initializes a new Record model for
// each row in the provided NullStringMap slice.
func NewRecordsFromNullStringMaps(collection *Collection, rows []dbx.NullStringMap) []*Record {
result := []*Record{}
result := make([]*Record, len(rows))
for _, row := range rows {
result = append(result, NewRecordFromNullStringMap(collection, row))
for i, row := range rows {
result[i] = NewRecordFromNullStringMap(collection, row)
}
return result
}
// Returns the table name associated to the current Record model.
// TableName returns the table name associated to the current Record model.
func (m *Record) TableName() string {
return m.collection.Name
}
// Returns the Collection model associated to the current Record model.
// Collection returns the Collection model associated to the current Record model.
func (m *Record) Collection() *Collection {
return m.collection
}
+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))
}
}
+1 -1
View File
@@ -134,7 +134,7 @@ func (s *Schema) AddField(newField *SchemaField) {
// checks for invalid renamed fields and field name duplications.
func (s Schema) Validate() error {
return validation.Validate(&s.fields, validation.Required, validation.By(func(value any) error {
fields := s.fields // use directly the schema value to avoid unnecesary interface casting
fields := s.fields // use directly the schema value to avoid unnecessary interface casting
if len(fields) == 0 {
return validation.NewError("validation_invalid_schema", "Invalid schema format.")
+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
+23 -17
View File
@@ -493,24 +493,27 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
value any
expectJson string
}{
{schema.SchemaField{Type: "unkown"}, "test", `"test"`},
{schema.SchemaField{Type: "unkown"}, 123, "123"},
{schema.SchemaField{Type: "unkown"}, []int{1, 2, 1}, "[1,2,1]"},
{schema.SchemaField{Type: "unknown"}, "test", `"test"`},
{schema.SchemaField{Type: "unknown"}, 123, "123"},
{schema.SchemaField{Type: "unknown"}, []int{1, 2, 1}, "[1,2,1]"},
// text
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeText}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeText}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeText}, 123, `"123"`},
// email
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeEmail}, 123, `"123"`},
// url
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, []int{1, 2}, `""`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeUrl}, 123, `"123"`},
@@ -523,7 +526,9 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeJson}, []int{1, 2, 1}, `[1,2,1]`},
// number
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, nil, "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "", "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "test", "0"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1, "1"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, 1.5, "1.5"},
{schema.SchemaField{Type: schema.FieldTypeNumber}, "1.5", "1.5"},
@@ -533,22 +538,24 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
{schema.SchemaField{Type: schema.FieldTypeBool}, 1, "true"},
{schema.SchemaField{Type: schema.FieldTypeBool}, 0, "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "test", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "false", "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, "true", "true"},
{schema.SchemaField{Type: schema.FieldTypeBool}, false, "false"},
{schema.SchemaField{Type: schema.FieldTypeBool}, true, "true"},
// date
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, "null"},
{schema.SchemaField{Type: schema.FieldTypeDate}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "test", `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, 1641024040, `"2022-01-01 08:00:40.000"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, "2022-01-01 11:27:10.123", `"2022-01-01 11:27:10.123"`},
{schema.SchemaField{Type: schema.FieldTypeDate}, types.DateTime{}, `""`},
{schema.SchemaField{Type: schema.FieldTypeDate}, time.Time{}, `""`},
// select (single)
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeSelect}, []string{"test1", "test2"}, `"test1"`},
@@ -624,8 +631,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// file (single)
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeFile}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeFile}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, "test", `"test"`},
{schema.SchemaField{Type: schema.FieldTypeFile}, []string{"test1", "test2"}, `"test1"`},
@@ -690,8 +697,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// relation (single)
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "abc", `"abc"`},
{schema.SchemaField{Type: schema.FieldTypeRelation}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
@@ -761,8 +768,8 @@ func TestSchemaFieldPrepareValue(t *testing.T) {
},
// user (single)
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `null`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `null`},
{schema.SchemaField{Type: schema.FieldTypeUser}, nil, `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "", `""`},
{schema.SchemaField{Type: schema.FieldTypeUser}, 123, `"123"`},
{schema.SchemaField{Type: schema.FieldTypeUser}, "1ba88b4f-e9da-42f0-9764-9a55c953e724", `"1ba88b4f-e9da-42f0-9764-9a55c953e724"`},
{
@@ -1281,7 +1288,6 @@ func TestFileOptionsValidate(t *testing.T) {
}
func TestRelationOptionsValidate(t *testing.T) {
scenarios := []fieldOptionsScenario{
{
"empty",
+2 -2
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"
)
+10 -9
View File
@@ -17,7 +17,7 @@ import (
var _ core.App = (*PocketBase)(nil)
// Version of PocketBase
const Version = "0.1.0"
var Version = "(untracked)"
// appWrapper serves as a private core.App instance wrapper.
type appWrapper struct {
@@ -51,9 +51,9 @@ type PocketBase struct {
// New creates a new PocketBase instance.
//
// Note that the application will not be initialized/bootstrapped yet,
// aka. DB connections, migrations, app settings, etc. will not be accessable.
// aka. DB connections, migrations, app settings, etc. will not be accessible.
// Everything will be initialized when Start() is executed.
// If you want initialize the application before calling Start(),
// If you want to initialize the application before calling Start(),
// then you'll have to manually call Bootstrap().
func New() *PocketBase {
// try to find the base executable directory and how it was run
@@ -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()
+1 -1
View File
@@ -168,7 +168,7 @@ 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,
+2 -2
View File
@@ -48,7 +48,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)
@@ -109,7 +109,7 @@ func (scenario *ApiScenario) Test(t *testing.T) {
}
for event, expectedCalls := range scenario.ExpectedEvents {
actualCalls, _ := testApp.EventCalls[event]
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("[%s] Expected event %s to be called %d, got %d", prefix, event, expectedCalls, actualCalls)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Pacakge tests provides common helpers and mocks used in PocketBase application tests.
// Package tests provides common helpers and mocks used in PocketBase application tests.
package tests
import (
Binary file not shown.
+1 -1
View File
@@ -29,7 +29,7 @@ type Provider interface {
// SetClientId sets the provider client's ID.
SetClientId(clientId string)
// ClientId returns the provider client's app secret.
// ClientSecret returns the provider client's app secret.
ClientSecret() string
// SetClientSecret sets the provider client's app secret.
+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)
}
+11 -7
View File
@@ -127,7 +127,7 @@ func (s *System) DeletePrefix(prefix string) []error {
Prefix: prefix,
}
// delete all files witht the prefix
// delete all files with the prefix
// ---
iter := s.bucket.List(&opts)
for {
@@ -153,7 +153,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)
}
@@ -182,14 +182,18 @@ func (s *System) Serve(response http.ResponseWriter, fileKey string, name string
}
defer r.Close()
// All HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
// (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1)
location, _ := time.LoadLocation("GMT")
response.Header().Set("Content-Disposition", "attachment; filename="+name)
response.Header().Set("Content-Type", r.ContentType())
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
// 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)
//
// NB! time.LoadLocation may fail on non-Unix systems (see https://github.com/pocketbase/pocketbase/issues/45)
location, locationErr := time.LoadLocation("GMT")
if locationErr == nil {
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
}
// copy from the read range to response.
_, err := io.Copy(response, r)
+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()
}
+5 -24
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."},
@@ -82,7 +86,7 @@ func TestSanitize(t *testing.T) {
{" ", ` `, "", false},
{"", `[A-Z]`, "", false},
{"abcABC", `[A-Z]`, "abc", false},
{"abcABC", `[A-Z`, "", true}, // invlid pattern
{"abcABC", `[A-Z`, "", true}, // invalid pattern
}
for i, scenario := range scenarios {
@@ -128,26 +132,3 @@ func TestSnakecase(t *testing.T) {
}
}
}
func TestUsernamify(t *testing.T) {
scenarios := []struct {
val string
expected string
}{
{"", "unknown"},
{" ", "unknown"},
{"!@#$%^", "unknown"},
{"...", "unknown"},
{"_", "_"}, // underscore is valid word character
{"John Doe", "john.doe"},
{"John_Doe", "john_doe"},
{".a!b@c#d$e%123. ", "a.b.c.d.e.123"},
{"Hello, world", "hello.world"},
}
for i, scenario := range scenarios {
if result := inflector.Usernamify(scenario.val); result != scenario.expected {
t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result)
}
}
}
+30 -29
View File
@@ -10,7 +10,7 @@ import (
var cachedPatterns = map[string]*regexp.Regexp{}
// ExustInSlice checks whether a comparable element exists in a slice of the same type.
// ExistInSlice checks whether a comparable element exists in a slice of the same type.
func ExistInSlice[T comparable](item T, list []T) bool {
if len(list) == 0 {
return false
@@ -38,21 +38,23 @@ func ExistInSliceWithRegex(str string, list []string) bool {
if str == field {
return true
}
} else {
// check for regex match
pattern, ok := cachedPatterns[field]
if !ok {
var patternErr error
pattern, patternErr = regexp.Compile(field)
if patternErr != nil {
continue
}
// "cache" the pattern to avoid compiling it every time
cachedPatterns[field] = pattern
}
if pattern != nil && pattern.MatchString(str) {
return true
continue
}
// check for regex match
pattern, ok := cachedPatterns[field]
if !ok {
var err error
pattern, err = regexp.Compile(field)
if err != nil {
continue
}
// "cache" the pattern to avoid compiling it every time
cachedPatterns[field] = pattern
}
if pattern != nil && pattern.MatchString(str) {
return true
}
}
@@ -72,46 +74,45 @@ func ToInterfaceSlice[T any](list []T) []any {
// NonzeroUniques returns only the nonzero unique values from a slice.
func NonzeroUniques[T comparable](list []T) []T {
result := []T{}
existMap := map[T]bool{}
result := make([]T, 0, len(list))
existMap := make(map[T]struct{}, len(list))
var zeroVal T
for _, val := range list {
if !existMap[val] && val != zeroVal {
existMap[val] = true
result = append(result, val)
if _, ok := existMap[val]; ok || val == zeroVal {
continue
}
existMap[val] = struct{}{}
result = append(result, val)
}
return result
}
// ToUniqueStringSlice casts `value` to a slice of non-zero unique strings.
func ToUniqueStringSlice(value any) []string {
strings := []string{}
func ToUniqueStringSlice(value any) (result []string) {
switch val := value.(type) {
case nil:
// nothing to cast
case []string:
strings = val
result = val
case string:
if val == "" {
break
}
// check if it is a json encoded array of strings
if err := json.Unmarshal([]byte(val), &strings); err != nil {
if err := json.Unmarshal([]byte(val), &result); err != nil {
// not a json array, just add the string as single array element
strings = append(strings, val)
result = append(result, val)
}
case json.Marshaler: // eg. JsonArray
raw, _ := val.MarshalJSON()
json.Unmarshal(raw, &strings)
_ = json.Unmarshal(raw, &result)
default:
strings = cast.ToStringSlice(value)
result = cast.ToStringSlice(value)
}
return NonzeroUniques(strings)
return NonzeroUniques(result)
}
-2
View File
@@ -37,8 +37,6 @@ func NewSmtpClient(
// SmtpClient defines a SMTP mail client structure that implements
// `mailer.Mailer` interface.
type SmtpClient struct {
mail *mailyak.MailYak
host string
port int
username string
+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 {:") +
".+" +
+21 -30
View File
@@ -121,8 +121,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 +129,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 +137,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,35 +161,30 @@ 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
@@ -210,7 +201,7 @@ func (s *Provider) Exec(items any) (*Result, error) {
s.perPage = MaxPerPage
}
// normalize page accoring to the total count
// normalize page according to the total count
if s.page <= 0 || totalCount == 0 {
s.page = 1
} else if totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage))); s.page > totalPages {
+5 -5
View File
@@ -273,8 +273,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
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)
@@ -286,8 +286,8 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
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
@@ -344,7 +344,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))
+2 -1
View File
@@ -9,6 +9,7 @@ import (
func TestRandomString(t *testing.T) {
generated := []string{}
reg := regexp.MustCompile(`[a-zA-Z0-9]+`)
for i := 0; i < 30; i++ {
length := 5 + i
@@ -18,7 +19,7 @@ func TestRandomString(t *testing.T) {
t.Errorf("(%d) Expected the length of the string to be %d, got %d", i, length, len(result))
}
if match, _ := regexp.MatchString("[a-zA-Z0-9]+", result); !match {
if match := reg.MatchString(result); !match {
t.Errorf("(%d) The generated strings should have only [a-zA-Z0-9]+ characters, got %q", i, result)
}
+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})
+2 -1
View File
@@ -1,10 +1,11 @@
package types_test
import (
"github.com/pocketbase/pocketbase/tools/types"
"strings"
"testing"
"time"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestNowDateTime(t *testing.T) {
+1
View File
@@ -2,3 +2,4 @@
PB_BACKEND_URL = /
PB_PROFILE_COLLECTION = profiles
PB_RULES_SYNTAX_DOCS = https://pocketbase.io/docs/manage-collections#rules-filters-syntax
PB_INSTALLER_PARAM = installer
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{S as t,i as n,s as o,K as a}from"./index.a658f3b6.js";function l(e){return a("/collections"),[]}class c extends t{constructor(s){super(),n(this,s,l,null,o,{})}}export{c as default};
@@ -0,0 +1,2 @@
import{S as E,i as G,s as I,F as K,c as F,m as B,t as H,a as N,d as T,C as M,q as J,e as c,w as q,b as k,f as u,r as L,g as b,h as _,u as h,v as O,j as Q,l as U,o as w,A as V,p as W,B as X,D as Y,x as Z,z as S}from"./index.0d48f812.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 +0,0 @@
import{S as I,i as K,s as L,F as W,f as F,m as H,n as B,o as J,q as N,H as M,D as q,e as c,g as u,h as b,j as _,E as O,p as w,z,d as k,A as D,B as T,C as Q,k as U,v as V,I as X,y as E,J as Y,K as Z,G as S}from"./index.a658f3b6.js";function G(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=T(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=T(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,h,m=f[3]&&G(f);return r=new z({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),d=new z({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],D(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),H(r,e,null),_(e,p),H(d,e,null),_(e,n),_(e,i),_(i,g),b(a,R,$),b(a,C,$),_(C,v),P=!0,A||(h=[T(e,"submit",Q(f[4])),U(V.call(null,v))],A=!0)},p(a,$){a[3]?m?m.p(a,$):(m=G(a),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const j={};$&769&&(j.$$scope={dirty:$,ctx:a}),r.$set(j);const y={};$&770&&(y.$$scope={dirty:$,ctx:a}),d.$set(y),(!P||$&4)&&(i.disabled=a[2]),$&4&&D(i,"btn-loading",a[2])},i(a){P||(B(r.$$.fragment,a),B(d.$$.fragment,a),P=!0)},o(a){J(r.$$.fragment,a),J(d.$$.fragment,a),P=!1},d(a){a&&w(e),m&&m.d(),N(r),N(d),a&&w(R),a&&w(C),A=!1,X(h)}}}function se(f){let e,o;return e=new W({props:{$$slots:{default:[te]},$$scope:{ctx:f}}}),{c(){F(e.$$.fragment)},m(s,l){H(e,s,l),o=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){o||(B(e.$$.fragment,s),o=!0)},o(s){J(e.$$.fragment,s),o=!1},d(s){N(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 E.Admins.confirmPasswordReset(l==null?void 0:l.token,t,r),Y("Successfully set a new admin password."),Z("/")}catch(g){E.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 I{constructor(e){super(),K(this,e,le,se,L,{params:5})}}export{ae as default};
@@ -1,2 +1,2 @@
import{S,i as B,s as M,F as T,f as A,m as E,n as w,o as y,q as H,d as g,e as _,g as p,h as k,j as d,k as j,v as z,w as D,x as G,p as v,y as C,z as N,A as F,B as L,C as I,D as h,E as J,u as P,G as R}from"./index.a658f3b6.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new N({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(),A(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),E(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=L(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),H(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=L(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=j(z.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(D(),y(a[$],1,1,()=>{a[$]=null}),G(),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 T({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){A(e.$$.fragment)},m(n,l){E(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){H(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 S{constructor(e){super(),B(this,e,W,V,M,{})}}export{Y as default};
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.0d48f812.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 z,i as A,s as B,F as D,f as S,m as U,n as $,o as v,q as j,H as G,L as J,h as _,w as M,x as N,p as b,y as P,D as y,e as m,g as d,j as g,E as R,z as W,d as C,A as F,B as E,C as Y,u as h,G as L}from"./index.a658f3b6.js";function I(r){let e,s,t,l,n,o,c,a,i,u,k,q,p=r[3]&&T(r);return o=new W({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],F(a,"btn-loading",r[1])},m(f,w){_(f,e,w),g(e,s),g(s,t),g(t,l),p&&p.m(t,null),g(e,n),U(o,e,null),g(e,c),g(e,a),g(a,i),u=!0,k||(q=E(e,"submit",Y(r[4])),k=!0)},p(f,w){f[3]?p?p.p(f,w):(p=T(f),p.c(),p.m(t,null)):p&&(p.d(1),p=null);const H={};w&769&&(H.$$scope={dirty:w,ctx:f}),o.$set(H),(!u||w&2)&&(a.disabled=f[1]),w&2&&F(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(),j(o),k=!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.0d48f812.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 T(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),g(s,t)},p(l,n){n&8&&R(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),g(e,s),_(i,l,u),_(i,n,u),L(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]&&L(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=J()},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):(M(),v(o[u],1,1,()=>{o[u]=null}),N(),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 D({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){j(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 P.Users.confirmEmailChange(l==null?void 0:l.token,n),s(2,c=!0)}catch(k){P.errorResponseHandler(k)}s(1,o=!1)}}const i=()=>window.close();function u(){n=this.value,s(0,n)}return r.$$set=k=>{"params"in k&&s(5,l=k.params)},r.$$.update=()=>{r.$$.dirty&32&&s(3,t=G.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[n,o,c,t,a,l,i,u]}class x extends z{constructor(e){super(),A(this,e,X,V,B,{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};
@@ -0,0 +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,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.0d48f812.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&&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,4 +0,0 @@
import{S as D,i as E,s as G,F as J,f as F,m as L,n as P,o as q,q as N,H as M,L as W,h as _,w as Y,x as I,p as m,y as j,D as y,e as b,j as w,E as K,z,d as C,g as c,A,B as R,C as O,u as h,G as H}from"./index.a658f3b6.js";function Q(r){let e,l,t,n,s,o,p,a,i,u,v,g,k,S,d=r[4]&&B(r);return o=new z({props:{class:"form-field required",name:"password",$$slots:{default:[X,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:r}}}),a=new z({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[Z,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:r}}}),{c(){e=b("form"),l=b("div"),t=b("h4"),n=y(`Reset your user password
`),d&&d.c(),s=C(),F(o.$$.fragment),p=C(),F(a.$$.fragment),i=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=r[2],A(u,"btn-loading",r[2])},m(f,$){_(f,e,$),w(e,l),w(l,t),w(t,n),d&&d.m(t,null),w(e,s),L(o,e,null),w(e,p),L(a,e,null),w(e,i),w(e,u),w(u,v),g=!0,k||(S=R(e,"submit",O(r[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=B(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&&A(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(),N(o),N(a),k=!1,S()}}}function V(r){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=R(t,"click",r[7]),n=!0)},p:h,i:h,o:h,d(o){o&&m(e),o&&m(l),o&&m(t),n=!1,s()}}}function B(r){let e,l,t;return{c(){e=y("for "),l=b("strong"),t=y(r[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(r){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=r[10]),c(s,"type","password"),c(s,"id",o=r[10]),s.required=!0,s.autofocus=!0},m(i,u){_(i,e,u),w(e,l),_(i,n,u),_(i,s,u),H(s,r[0]),s.focus(),p||(a=R(s,"input",r[8]),p=!0)},p(i,u){u&1024&&t!==(t=i[10])&&c(e,"for",t),u&1024&&o!==(o=i[10])&&c(s,"id",o),u&1&&s.value!==i[0]&&H(s,i[0])},d(i){i&&m(e),i&&m(n),i&&m(s),p=!1,a()}}}function Z(r){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=r[10]),c(s,"type","password"),c(s,"id",o=r[10]),s.required=!0},m(i,u){_(i,e,u),w(e,l),_(i,n,u),_(i,s,u),H(s,r[1]),p||(a=R(s,"input",r[9]),p=!0)},p(i,u){u&1024&&t!==(t=i[10])&&c(e,"for",t),u&1024&&o!==(o=i[10])&&c(s,"id",o),u&2&&s.value!==i[1]&&H(s,i[1])},d(i){i&&m(e),i&&m(n),i&&m(s),p=!1,a()}}}function x(r){let e,l,t,n;const s=[V,Q],o=[];function p(a,i){return a[3]?0:1}return e=p(r),l=o[e]=s[e](r),{c(){l.c(),t=W()},m(a,i){o[e].m(a,i),_(a,t,i),n=!0},p(a,i){let u=e;e=p(a),e===u?o[e].p(a,i):(Y(),q(o[u],1,1,()=>{o[u]=null}),I(),l=o[e],l?l.p(a,i):(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(r){let e,l;return e=new J({props:{nobranding:!0,$$slots:{default:[x]},$$scope:{ctx:r}}}),{c(){F(e.$$.fragment)},m(t,n){L(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){N(e,t)}}}function te(r,e,l){let t,{params:n}=e,s="",o="",p=!1,a=!1;async function i(){if(!p){l(2,p=!0);try{await j.Users.confirmPasswordReset(n==null?void 0:n.token,s,o),l(3,a=!0)}catch(k){j.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 r.$$set=k=>{"params"in k&&l(6,n=k.params)},r.$$.update=()=>{r.$$.dirty&64&&l(4,t=M.getJWTPayload(n==null?void 0:n.token).email||"")},[s,o,p,a,t,i,n,u,v,g]}class le extends D{constructor(e){super(),E(this,e,te,ee,G,{params:6})}}export{le as default};

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