diff --git a/.github/FUNDING.yaml b/.github/FUNDING.yaml new file mode 100644 index 000000000..6f504dc0f --- /dev/null +++ b/.github/FUNDING.yaml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: ['https://www.paypal.com/donate?hosted_button_id=S98EMBN3G3HZY'] diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..7789ca288 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,39 @@ +name: basebuild + +on: + pull_request: + push: + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: latest + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + + # This step usually is not needed because the /ui/dist is pregenerated locally + # but its here to ensure that each release embeds the latest admin ui artifacts. + # If the artificats differs, a "dirty error" is thrown - https://goreleaser.com/errors/dirty/ + - name: Build Admin dashboard UI + run: npm --prefix=./ui ci && npm --prefix=./ui run build + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v3 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5d03e3d29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/.vscode/ + +.DS_Store + +# goreleaser builds folder +/.builds/ + +# examples app directories +pb_data +pb_public + +# tests coverage +coverage.out + +# plaintask todo files +*.todo diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 000000000..42762bc31 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,43 @@ +project_name: pocketbase + +dist: .builds + +before: + hooks: + - go mod tidy + +builds: + - main: ./examples/base + binary: pocketbase + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + +release: + draft: true + +archives: + - + format: zip + files: + - LICENSE* + - CHANGELOG* + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^examples:' + - '^ui:' diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..26265aa2b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,17 @@ +The MIT License (MIT) +Copyright (c) 2022, Gani Georgiev + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..71f5224db --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +
+ + + +
+ + + +[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of: + +- embedded database (_SQLite_) with **realtime subscriptions** +- backed-in **files and users management** +- convenient **Admin dashboard UI** +- and simple **REST-ish API** + +**For documentation and examples, please visit https://pocketbase.io/docs.** + +> ⚠️ Although the web API defintions 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. + + +## API SDK clients + +The easiest way to interact with the API is to use one of the official SDK clients: + +- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_) +- **Dart** - _soon_ + + +## Overview + +PocketBase could be used as a standalone app or as a Go framework/toolkit that enables you to build +your own custom app specific business logic and still have a single portable executable at the end. + +### Installation + +```sh +# go 1.18+ +go get github.com/pocketbase/pocketbase +``` + +### Example + +```go +package main + +import ( + "log" + "net/http" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func main() { + app := pocketbase.New() + + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + // add new "GET /api/hello" route to the app router (echo) + e.Router.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/api/hello", + Handler: func(c echo.Context) error { + return c.String(200, "Hello world!") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrUserAuth(), + }, + }) + + return nil + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} +``` + +### Running and building + +Running/building the application is the same as for any other Go program, aka. just `go run` and `go build`. + +**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). + +Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation. + +### Testing + +PocketBase comes with mixed bag of unit and integration tests. +To run them, use the default `go test` command: +```sh +go test ./... +``` + +Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how to write your own custom application tests. + +## Security + +If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**. + +All reports will be promptly addressed and you'll be credited accordingly. + + +## Contributing + +PocketBase is free and open source project licensed under the [MIT License](LICENSE.md). + +You could help continuing its development by: + +- [Suggest new features, report issues and fix bugs](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)), +so all feature requests will be first aligned with what we need for Presentator v3. diff --git a/apis/admin.go b/apis/admin.go new file mode 100644 index 000000000..51276db4e --- /dev/null +++ b/apis/admin.go @@ -0,0 +1,261 @@ +package apis + +import ( + "log" + "net/http" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tokens" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/routine" + "github.com/pocketbase/pocketbase/tools/search" +) + +// BindAdminApi registers the admin api endpoints and the corresponding handlers. +func BindAdminApi(app core.App, rg *echo.Group) { + api := adminApi{app: app} + + subGroup := rg.Group("/admins", ActivityLogger(app)) + subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly()) + subGroup.POST("/request-password-reset", api.requestPasswordReset) + subGroup.POST("/confirm-password-reset", api.confirmPasswordReset) + subGroup.POST("/refresh", api.refresh, RequireAdminAuth()) + subGroup.GET("", api.list, RequireAdminAuth()) + subGroup.POST("", api.create, RequireAdminAuth()) + subGroup.GET("/:id", api.view, RequireAdminAuth()) + subGroup.PATCH("/:id", api.update, RequireAdminAuth()) + subGroup.DELETE("/:id", api.delete, RequireAdminAuth()) +} + +type adminApi struct { + app core.App +} + +func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error { + token, tokenErr := tokens.NewAdminAuthToken(api.app, admin) + if tokenErr != nil { + return rest.NewBadRequestError("Failed to create auth token.", tokenErr) + } + + event := &core.AdminAuthEvent{ + HttpContext: c, + Admin: admin, + Token: token, + } + + return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error { + return e.HttpContext.JSON(200, map[string]any{ + "token": e.Token, + "admin": e.Admin, + }) + }) +} + +func (api *adminApi) refresh(c echo.Context) error { + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil { + return rest.NewNotFoundError("Missing auth admin context.", nil) + } + + return api.authResponse(c, admin) +} + +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) + } + + admin, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to authenticate.", submitErr) + } + + return api.authResponse(c, admin) +} + +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) + } + + if err := form.Validate(); err != nil { + return rest.NewBadRequestError("An error occured while validating the form.", err) + } + + // run in background because we don't need to show the result + // (prevents admins enumeration) + routine.FireAndForget(func() { + if err := form.Submit(); err != nil && api.app.IsDebug() { + log.Println(err) + } + }) + + return c.NoContent(http.StatusNoContent) +} + +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) + } + + admin, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to set new password.", submitErr) + } + + return api.authResponse(c, admin) +} + +func (api *adminApi) list(c echo.Context) error { + fieldResolver := search.NewSimpleFieldResolver( + "id", "created", "updated", "name", "email", + ) + + admins := []*models.Admin{} + + result, err := search.NewProvider(fieldResolver). + Query(api.app.Dao().AdminQuery()). + ParseAndExec(c.QueryString(), &admins) + + if err != nil { + return rest.NewBadRequestError("", err) + } + + event := &core.AdminsListEvent{ + HttpContext: c, + Admins: admins, + Result: result, + } + + return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Result) + }) +} + +func (api *adminApi) view(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + admin, err := api.app.Dao().FindAdminById(id) + if err != nil || admin == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.AdminViewEvent{ + HttpContext: c, + Admin: admin, + } + + return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Admin) + }) +} + +func (api *adminApi) create(c echo.Context) error { + admin := &models.Admin{} + + form := forms.NewAdminUpsert(api.app, admin) + + // load request + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.AdminCreateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Admin) + }) + + if handlerErr == nil { + api.app.OnAdminAfterCreateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *adminApi) update(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + admin, err := api.app.Dao().FindAdminById(id) + if err != nil || admin == nil { + return rest.NewNotFoundError("", err) + } + + form := forms.NewAdminUpsert(api.app, admin) + + // load request + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.AdminUpdateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Admin) + }) + + if handlerErr == nil { + api.app.OnAdminAfterUpdateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *adminApi) delete(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + admin, err := api.app.Dao().FindAdminById(id) + if err != nil || admin == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.AdminDeleteEvent{ + HttpContext: c, + Admin: admin, + } + + handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error { + if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil { + return rest.NewBadRequestError("Failed to delete admin.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + + if handlerErr == nil { + api.app.OnAdminAfterDeleteRequest().Trigger(event) + } + + return handlerErr +} diff --git a/apis/admin_test.go b/apis/admin_test.go new file mode 100644 index 000000000..18da81af9 --- /dev/null +++ b/apis/admin_test.go @@ -0,0 +1,654 @@ +package apis_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestAdminAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/admins/auth-via-email", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/admins/auth-via-email", + Body: strings.NewReader(`{`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "wrong email/password", + Method: http.MethodPost, + Url: "/api/admins/auth-via-email", + Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "valid email/password (already authorized)", + Method: http.MethodPost, + Url: "/api/admins/auth-via-email", + Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`}, + }, + { + Name: "valid email/password (guest)", + Method: http.MethodPost, + Url: "/api/admins/auth-via-email", + Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, + `"token":`, + }, + ExpectedEvents: map[string]int{ + "OnAdminAuthRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminRequestPasswordReset(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/admins/request-password-reset", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/admins/request-password-reset", + Body: strings.NewReader(`{"email`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing admin", + Method: http.MethodPost, + Url: "/api/admins/request-password-reset", + Body: strings.NewReader(`{"email":"missing@example.com"}`), + ExpectedStatus: 204, + }, + { + Name: "existing admin", + Method: http.MethodPost, + Url: "/api/admins/request-password-reset", + Body: strings.NewReader(`{"email":"test@example.com"}`), + ExpectedStatus: 204, + // usually this events are fired but since the submit is + // executed in a separate go routine they are fired async + // ExpectedEvents: map[string]int{ + // "OnModelBeforeUpdate": 1, + // "OnModelAfterUpdate": 1, + // "OnMailerBeforeUserResetPasswordSend:1": 1, + // "OnMailerAfterUserResetPasswordSend:1": 1, + // }, + }, + { + Name: "existing admin (after already sent)", + Method: http.MethodPost, + Url: "/api/admins/request-password-reset", + Body: strings.NewReader(`{"email":"test@example.com"}`), + ExpectedStatus: 204, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminConfirmPasswordReset(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/admins/confirm-password-reset", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/admins/confirm-password-reset", + Body: strings.NewReader(`{"password`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expired token", + Method: http.MethodPost, + Url: "/api/admins/confirm-password-reset", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`}, + }, + { + Name: "valid token", + Method: http.MethodPost, + Url: "/api/admins/confirm-password-reset", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, + `"token":`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnAdminAuthRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminRefresh(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPost, + Url: "/api/admins/refresh", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPost, + Url: "/api/admins/refresh", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodPost, + Url: "/api/admins/refresh", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, + `"token":`, + }, + ExpectedEvents: map[string]int{ + "OnAdminAuthRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminsList(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/admins", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/admins", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodGet, + Url: "/api/admins", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":2`, + `"items":[{`, + `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, + `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, + }, + ExpectedEvents: map[string]int{ + "OnAdminsListRequest": 1, + }, + }, + { + Name: "authorized as admin + paging and sorting", + Method: http.MethodGet, + Url: "/api/admins?page=2&perPage=1&sort=-created", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":2`, + `"perPage":1`, + `"totalItems":2`, + `"items":[{`, + `"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`, + }, + ExpectedEvents: map[string]int{ + "OnAdminsListRequest": 1, + }, + }, + { + Name: "authorized as admin + invalid filter", + Method: http.MethodGet, + Url: "/api/admins?filter=invalidfield~'test2'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + valid filter", + Method: http.MethodGet, + Url: "/api/admins?filter=email~'test2'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":1`, + `"items":[{`, + `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, + }, + ExpectedEvents: map[string]int{ + "OnAdminsListRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminView(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + invalid admin id", + Method: http.MethodGet, + Url: "/api/admins/invalid", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting admin id", + Method: http.MethodGet, + Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + existing admin id", + Method: http.MethodGet, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, + }, + ExpectedEvents: map[string]int{ + "OnAdminViewRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminDelete(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodDelete, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodDelete, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + invalid admin id", + Method: http.MethodDelete, + Url: "/api/admins/invalid", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting admin id", + Method: http.MethodDelete, + Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + existing admin id", + Method: http.MethodDelete, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnModelBeforeDelete": 1, + "OnModelAfterDelete": 1, + "OnAdminBeforeDeleteRequest": 1, + "OnAdminAfterDeleteRequest": 1, + }, + }, + { + Name: "authorized as admin - try to delete the only remaining admin", + Method: http.MethodDelete, + Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + // delete all admins except the authorized one + adminModel := &models.Admin{} + _, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{ + "id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + })).Execute() + if err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + ExpectedEvents: map[string]int{ + "OnAdminBeforeDeleteRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminCreate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPost, + Url: "/api/admins", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPost, + Url: "/api/admins", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + empty data", + Method: http.MethodPost, + Url: "/api/admins", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + 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", + Method: http.MethodPost, + Url: "/api/admins", + Body: strings.NewReader(`{`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + invalid data", + Method: http.MethodPost, + Url: "/api/admins", + Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + 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", + Method: http.MethodPost, + Url: "/api/admins", + Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"email":"testnew@example.com"`, + `"avatar":3`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + "OnAdminBeforeCreateRequest": 1, + "OnAdminAfterCreateRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestAdminUpdate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPatch, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPatch, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + invalid admin id", + Method: http.MethodPatch, + Url: "/api/admins/invalid", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting admin id", + Method: http.MethodPatch, + Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + empty data", + Method: http.MethodPatch, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, + `"email":"test2@example.com"`, + `"avatar":2`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnAdminBeforeUpdateRequest": 1, + "OnAdminAfterUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin + invalid formatted data", + Method: http.MethodPatch, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + Body: strings.NewReader(`{`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + invalid data", + Method: http.MethodPatch, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + 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, + Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", + Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`, + `"email":"testnew@example.com"`, + `"avatar":5`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnAdminBeforeUpdateRequest": 1, + "OnAdminAfterUpdateRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/base.go b/apis/base.go new file mode 100644 index 000000000..c09f4f757 --- /dev/null +++ b/apis/base.go @@ -0,0 +1,131 @@ +// Package apis implements the default PocketBase api services and middlewares. +package apis + +import ( + "fmt" + "io/fs" + "log" + "net/http" + "net/url" + "path/filepath" + "strings" + + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/ui" +) + +// InitApi creates a configured echo instance with registered +// system and app specific routes and middlewares. +func InitApi(app core.App) (*echo.Echo, error) { + e := echo.New() + e.Debug = app.IsDebug() + + // default middlewares + e.Pre(middleware.RemoveTrailingSlash()) + e.Use(middleware.Recover()) + e.Use(middleware.Secure()) + e.Use(LoadAuthContext(app)) + + // custom error handler + e.HTTPErrorHandler = func(c echo.Context, err error) { + if c.Response().Committed { + return + } + + var apiErr *rest.ApiError + + switch v := err.(type) { + 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): + if app.IsDebug() && v.RawData() != nil { + log.Println(v.RawData()) + } + apiErr = v + default: + if err != nil && app.IsDebug() { + log.Println(err) + } + apiErr = rest.NewBadRequestError("", err) + } + + // Send response + var cErr error + if c.Request().Method == http.MethodHead { + // @see https://github.com/labstack/echo/issues/608 + cErr = c.NoContent(apiErr.Code) + } else { + cErr = c.JSON(apiErr.Code, apiErr) + } + + // truly rare case; eg. client already disconnected + if cErr != nil && app.IsDebug() { + log.Println(err) + } + } + + // 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()) + + // default routes + api := e.Group("/api") + BindSettingsApi(app, api) + BindAdminApi(app, api) + BindUserApi(app, api) + BindCollectionApi(app, api) + BindRecordApi(app, api) + BindFileApi(app, api) + BindRealtimeApi(app, api) + BindLogsApi(app, api) + + // trigger the custom BeforeServe hook for the created api router + // allowing users to further adjust its options or register new routes + serveEvent := &core.ServeEvent{ + App: app, + Router: e, + } + if err := app.OnBeforeServe().Trigger(serveEvent); err != nil { + return nil, err + } + + // catch all any route + api.Any("/*", func(c echo.Context) error { + return echo.ErrNotFound + }, ActivityLogger(app)) + + return e, nil +} + +// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler` +// but without the directory redirect which conflicts with RemoveTrailingSlash middleware. +// +// @see https://github.com/labstack/echo/issues/2211 +func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc { + return func(c echo.Context) error { + p := c.PathParam("*") + if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice + tmpPath, err := url.PathUnescape(p) + if err != nil { + return fmt.Errorf("failed to unescape path variable: %w", err) + } + p = tmpPath + } + + // fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid + name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/"))) + + return c.FileFS(name, fileSystem) + } +} diff --git a/apis/base_test.go b/apis/base_test.go new file mode 100644 index 000000000..4a29bdaa1 --- /dev/null +++ b/apis/base_test.go @@ -0,0 +1,122 @@ +package apis_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/rest" +) + +func Test404(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Method: http.MethodGet, + Url: "/api/missing", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Method: http.MethodPost, + Url: "/api/missing", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Method: http.MethodPatch, + Url: "/api/missing", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Method: http.MethodDelete, + Url: "/api/missing", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Method: http.MethodHead, + Url: "/api/missing", + ExpectedStatus: 404, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestCustomRoutesAndErrorsHandling(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "custom route", + Method: http.MethodGet, + Url: "/custom", + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/custom", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + { + Name: "route with HTTPError", + Method: http.MethodGet, + Url: "/http-error", + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/http-error", + Handler: func(c echo.Context) error { + return echo.ErrBadRequest + }, + }) + }, + ExpectedStatus: 400, + ExpectedContent: []string{`{"code":400,"message":"Bad Request.","data":{}}`}, + }, + { + Name: "route with api error", + Method: http.MethodGet, + Url: "/api-error", + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/api-error", + Handler: func(c echo.Context) error { + return rest.NewApiError(500, "test message", errors.New("internal_test")) + }, + }) + }, + ExpectedStatus: 500, + ExpectedContent: []string{`{"code":500,"message":"Test message.","data":{}}`}, + }, + { + Name: "route with plain error", + Method: http.MethodGet, + Url: "/plain-error", + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/plain-error", + Handler: func(c echo.Context) error { + return errors.New("Test error") + }, + }) + }, + ExpectedStatus: 400, + ExpectedContent: []string{`{"code":400,"message":"Something went wrong while processing your request.","data":{}}`}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/collection.go b/apis/collection.go new file mode 100644 index 000000000..7fd9b33c5 --- /dev/null +++ b/apis/collection.go @@ -0,0 +1,185 @@ +package apis + +import ( + "errors" + "log" + "net/http" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/search" +) + +// BindCollectionApi registers the collection api endpoints and the corresponding handlers. +func BindCollectionApi(app core.App, rg *echo.Group) { + api := collectionApi{app: app} + + subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth()) + subGroup.GET("", api.list) + subGroup.POST("", api.create) + subGroup.GET("/:collection", api.view) + subGroup.PATCH("/:collection", api.update) + subGroup.DELETE("/:collection", api.delete) +} + +type collectionApi struct { + app core.App +} + +func (api *collectionApi) list(c echo.Context) error { + fieldResolver := search.NewSimpleFieldResolver( + "id", "created", "updated", "name", "system", + ) + + collections := []*models.Collection{} + + result, err := search.NewProvider(fieldResolver). + Query(api.app.Dao().CollectionQuery()). + ParseAndExec(c.QueryString(), &collections) + + if err != nil { + return rest.NewBadRequestError("", err) + } + + event := &core.CollectionsListEvent{ + HttpContext: c, + Collections: collections, + Result: result, + } + + return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Result) + }) +} + +func (api *collectionApi) view(c echo.Context) error { + collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) + if err != nil || collection == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.CollectionViewEvent{ + HttpContext: c, + Collection: collection, + } + + return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Collection) + }) +} + +func (api *collectionApi) create(c echo.Context) error { + collection := &models.Collection{} + + form := forms.NewCollectionUpsert(api.app, collection) + + // read + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.CollectionCreateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Collection) + }) + + if handlerErr == nil { + api.app.OnCollectionAfterCreateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *collectionApi) update(c echo.Context) error { + collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) + if err != nil || collection == nil { + return rest.NewNotFoundError("", err) + } + + form := forms.NewCollectionUpsert(api.app, collection) + + // read + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.CollectionUpdateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Collection) + }) + + if handlerErr == nil { + api.app.OnCollectionAfterUpdateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *collectionApi) delete(c echo.Context) error { + collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection")) + if err != nil || collection == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.CollectionDeleteEvent{ + HttpContext: c, + Collection: collection, + } + + handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error { + if err := api.app.Dao().DeleteCollection(e.Collection); err != nil { + 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) + }) + + if handlerErr == nil { + api.app.OnCollectionAfterDeleteRequest().Trigger(event) + } + + 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 +} diff --git a/apis/collection_test.go b/apis/collection_test.go new file mode 100644 index 000000000..09f306e54 --- /dev/null +++ b/apis/collection_test.go @@ -0,0 +1,451 @@ +package apis_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tests" +) + +func TestCollectionsList(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/collections", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/collections", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodGet, + Url: "/api/collections", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":5`, + `"items":[{`, + `"id":"abe78266-fd4d-4aea-962d-8c0138ac522b"`, + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + `"id":"3cd6fe92-70dc-4819-8542-4d036faabd89"`, + `"id":"f12f3eb6-b980-4bf6-b1e4-36de0450c8be"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionsListRequest": 1, + }, + }, + { + Name: "authorized as admin + paging and sorting", + Method: http.MethodGet, + Url: "/api/collections?page=2&perPage=2&sort=-created", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":2`, + `"perPage":2`, + `"totalItems":5`, + `"items":[{`, + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionsListRequest": 1, + }, + }, + { + Name: "authorized as admin + invalid filter", + Method: http.MethodGet, + Url: "/api/collections?filter=invalidfield~'demo2'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + valid filter", + Method: http.MethodGet, + Url: "/api/collections?filter=name~'demo2'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":1`, + `"items":[{`, + `"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionsListRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestCollectionView(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/collections/demo", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/collections/demo", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting collection identifier", + Method: http.MethodGet, + Url: "/api/collections/missing", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + using the collection name", + Method: http.MethodGet, + Url: "/api/collections/demo", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionViewRequest": 1, + }, + }, + { + Name: "authorized as admin + using the collection id", + Method: http.MethodGet, + Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionViewRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestCollectionDelete(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodDelete, + Url: "/api/collections/demo3", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodDelete, + Url: "/api/collections/demo3", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting collection identifier", + Method: http.MethodDelete, + Url: "/api/collections/b97ccf83-34a2-4d01-a26b-3d77bc842d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + using the collection name", + Method: http.MethodDelete, + Url: "/api/collections/demo3", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnModelBeforeDelete": 1, + "OnModelAfterDelete": 1, + "OnCollectionBeforeDeleteRequest": 1, + "OnCollectionAfterDeleteRequest": 1, + }, + }, + { + Name: "authorized as admin + using the collection id", + Method: http.MethodDelete, + Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnModelBeforeDelete": 1, + "OnModelAfterDelete": 1, + "OnCollectionBeforeDeleteRequest": 1, + "OnCollectionAfterDeleteRequest": 1, + }, + }, + { + Name: "authorized as admin + trying to delete a system collection", + Method: http.MethodDelete, + Url: "/api/collections/profiles", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + ExpectedEvents: map[string]int{ + "OnCollectionBeforeDeleteRequest": 1, + }, + }, + { + Name: "authorized as admin + trying to delete a referenced collection", + Method: http.MethodDelete, + Url: "/api/collections/demo", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + ExpectedEvents: map[string]int{ + "OnCollectionBeforeDeleteRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestCollectionCreate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPost, + Url: "/api/collections", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPost, + Url: "/api/collections", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + empty data", + Method: http.MethodPost, + Url: "/api/collections", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"name":{"code":"validation_required"`, + `"schema":{"code":"validation_required"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionBeforeCreateRequest": 1, + }, + }, + { + Name: "authorized as admin + invalid data (eg. existing name)", + Method: http.MethodPost, + Url: "/api/collections", + Body: strings.NewReader(`{"name":"demo","schema":[{"type":"text","name":""}]}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"name":{"code":"validation_collection_name_exists"`, + `"schema":{"0":{"name":{"code":"validation_required"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionBeforeCreateRequest": 1, + }, + }, + { + Name: "authorized as admin + valid data", + Method: http.MethodPost, + Url: "/api/collections", + Body: strings.NewReader(`{"name":"new","schema":[{"type":"text","id":"12345789","name":"test"}]}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"name":"new"`, + `"system":false`, + `"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + "OnCollectionBeforeCreateRequest": 1, + "OnCollectionAfterCreateRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestCollectionUpdate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPatch, + Url: "/api/collections/demo", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPatch, + Url: "/api/collections/demo", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + empty data", + Method: http.MethodPatch, + Url: "/api/collections/demo", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnCollectionBeforeUpdateRequest": 1, + "OnCollectionAfterUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin + invalid data (eg. existing name)", + Method: http.MethodPatch, + Url: "/api/collections/demo", + Body: strings.NewReader(`{"name":"demo2"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"name":{"code":"validation_collection_name_exists"`, + }, + ExpectedEvents: map[string]int{ + "OnCollectionBeforeUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin + valid data", + Method: http.MethodPatch, + Url: "/api/collections/demo", + Body: strings.NewReader(`{"name":"new"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"name":"new"`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnCollectionBeforeUpdateRequest": 1, + "OnCollectionAfterUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin + valid data and id as identifier", + Method: http.MethodPatch, + Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc", + Body: strings.NewReader(`{"name":"new"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"name":"new"`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnCollectionBeforeUpdateRequest": 1, + "OnCollectionAfterUpdateRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/file.go b/apis/file.go new file mode 100644 index 000000000..160acb63e --- /dev/null +++ b/apis/file.go @@ -0,0 +1,104 @@ +package apis + +import ( + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/rest" +) + +var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"} +var defaultThumbSizes = []string{"100x100"} + +// BindFileApi registers the file api endpoints and the corresponding handlers. +func BindFileApi(app core.App, rg *echo.Group) { + api := fileApi{app: app} + + subGroup := rg.Group("/files", ActivityLogger(app)) + subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app)) +} + +type fileApi struct { + app core.App +} + +func (api *fileApi) download(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", nil) + } + + recordId := c.PathParam("recordId") + if recordId == "" { + return rest.NewNotFoundError("", nil) + } + + record, err := api.app.Dao().FindRecordById(collection, recordId, nil) + if err != nil { + return rest.NewNotFoundError("", err) + } + + filename := c.PathParam("filename") + + fileField := record.FindFileFieldByFile(filename) + if fileField == nil { + return rest.NewNotFoundError("", nil) + } + options, _ := fileField.Options.(*schema.FileOptions) + + fs, err := api.app.NewFilesystem() + if err != nil { + return rest.NewBadRequestError("Filesystem initialization failure.", err) + } + defer fs.Close() + + originalPath := record.BaseFilesPath() + "/" + filename + servedPath := originalPath + servedName := filename + + // check for valid thumb size param + thumbSize := c.QueryParam("thumb") + if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) { + // extract the original file meta attributes and check it existence + oAttrs, oAttrsErr := fs.Attributes(originalPath) + if oAttrsErr != nil { + return rest.NewNotFoundError("", err) + } + + // check if it is an image + if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) { + // add thumb size as file suffix + servedName = thumbSize + "_" + filename + servedPath = record.BaseFilesPath() + "/thumbs_" + filename + "/" + servedName + + // check if the thumb exists: + // - if doesn't exist - create a new thumb with the specified thumb size + // - if exists - compare last modified dates to determine whether the thumb should be recreated + tAttrs, tAttrsErr := fs.Attributes(servedPath) + if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) { + if err := fs.CreateThumb(originalPath, servedPath, thumbSize, false); err != nil { + servedPath = originalPath // fallback to the original + } + } + } + } + + event := &core.FileDownloadEvent{ + HttpContext: c, + Record: record, + Collection: collection, + FileField: fileField, + ServedPath: servedPath, + ServedName: servedName, + } + + return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error { + if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil { + return rest.NewNotFoundError("", err) + } + + return nil + }) +} diff --git a/apis/file_test.go b/apis/file_test.go new file mode 100644 index 000000000..1805a95ce --- /dev/null +++ b/apis/file_test.go @@ -0,0 +1,102 @@ +package apis_test + +import ( + "github.com/pocketbase/pocketbase/tests" + "net/http" + "os" + "path" + "path/filepath" + "runtime" + "testing" +) + +func TestFileDownload(t *testing.T) { + _, currentFile, _, _ := runtime.Caller(0) + dataDirRelPath := "../tests/data/" + testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt") + testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png") + testThumbPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/100x100_4881bdef-06b4-4dea-8d97-6125ad242677.png") + + testFile, fileErr := os.ReadFile(testFilePath) + if fileErr != nil { + t.Fatal(fileErr) + } + + testImg, imgErr := os.ReadFile(testImgPath) + if imgErr != nil { + t.Fatal(imgErr) + } + + testThumb, thumbErr := os.ReadFile(testThumbPath) + if thumbErr != nil { + t.Fatal(thumbErr) + } + + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodGet, + Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record", + Method: http.MethodGet, + Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing file", + Method: http.MethodGet, + Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "existing image", + Method: http.MethodGet, + Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png", + ExpectedStatus: 200, + ExpectedContent: []string{string(testImg)}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "existing image - missing thumb (should fallback to the original)", + Method: http.MethodGet, + Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999", + ExpectedStatus: 200, + ExpectedContent: []string{string(testImg)}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "existing image - existing thumb", + Method: http.MethodGet, + Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=100x100", + ExpectedStatus: 200, + ExpectedContent: []string{string(testThumb)}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + { + Name: "existing non image file - thumb parameter should be ignored", + Method: http.MethodGet, + Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100", + ExpectedStatus: 200, + ExpectedContent: []string{string(testFile)}, + ExpectedEvents: map[string]int{ + "OnFileDownloadRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/logs.go b/apis/logs.go new file mode 100644 index 000000000..d055047f6 --- /dev/null +++ b/apis/logs.go @@ -0,0 +1,82 @@ +package apis + +import ( + "net/http" + + "github.com/pocketbase/dbx" + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/search" +) + +// BindLogsApi registers the request logs api endpoints. +func BindLogsApi(app core.App, rg *echo.Group) { + api := logsApi{app: app} + + subGroup := rg.Group("/logs", RequireAdminAuth()) + subGroup.GET("/requests", api.requestsList) + subGroup.GET("/requests/stats", api.requestsStats) + subGroup.GET("/requests/:id", api.requestView) +} + +type logsApi struct { + app core.App +} + +var requestFilterFields = []string{ + "rowid", "id", "created", "updated", + "url", "method", "status", "auth", + "ip", "referer", "userAgent", +} + +func (api *logsApi) requestsList(c echo.Context) error { + fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) + + result, err := search.NewProvider(fieldResolver). + Query(api.app.LogsDao().RequestQuery()). + ParseAndExec(c.QueryString(), &[]*models.Request{}) + + if err != nil { + return rest.NewBadRequestError("", err) + } + + return c.JSON(http.StatusOK, result) +} + +func (api *logsApi) requestsStats(c echo.Context) error { + fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...) + + filter := c.QueryParam(search.FilterQueryParam) + + var expr dbx.Expression + if filter != "" { + var err error + expr, err = search.FilterData(filter).BuildExpr(fieldResolver) + if err != nil { + return rest.NewBadRequestError("Invalid filter format.", err) + } + } + + stats, err := api.app.LogsDao().RequestsStats(expr) + if err != nil { + return rest.NewBadRequestError("Failed to generate requests stats.", err) + } + + return c.JSON(http.StatusOK, stats) +} + +func (api *logsApi) requestView(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + request, err := api.app.LogsDao().FindRequestById(id) + if err != nil || request == nil { + return rest.NewNotFoundError("", err) + } + + return c.JSON(http.StatusOK, request) +} diff --git a/apis/logs_test.go b/apis/logs_test.go new file mode 100644 index 000000000..0e1607aa3 --- /dev/null +++ b/apis/logs_test.go @@ -0,0 +1,196 @@ +package apis_test + +import ( + "net/http" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/tests" +) + +func TestRequestsList(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/logs/requests", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/logs/requests", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodGet, + Url: "/api/logs/requests", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":2`, + `"items":[{`, + `"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`, + `"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`, + }, + }, + { + Name: "authorized as admin + filter", + Method: http.MethodGet, + Url: "/api/logs/requests?filter=status>200", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":1`, + `"items":[{`, + `"id":"f2133873-44fb-9f38-bf82-c918f53b310d"`, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequestView(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin (nonexisting request log)", + Method: http.MethodGet, + Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin (existing request log)", + Method: http.MethodGet, + Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"873f2133-9f38-44fb-bf82-c8f53b310d91"`, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequestsStats(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/logs/requests/stats", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/logs/requests/stats", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodGet, + Url: "/api/logs/requests/stats", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`, + }, + }, + { + Name: "authorized as admin + filter", + Method: http.MethodGet, + Url: "/api/logs/requests/stats?filter=status>200", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if err := tests.MockRequestLogsData(app); err != nil { + t.Fatal(err) + } + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `[{"total":1,"date":"2022-05-02 10:00:00.000"}]`, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/middlewares.go b/apis/middlewares.go new file mode 100644 index 000000000..abf18e4d4 --- /dev/null +++ b/apis/middlewares.go @@ -0,0 +1,277 @@ +package apis + +import ( + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/routine" + "github.com/pocketbase/pocketbase/tools/types" + "github.com/spf13/cast" +) + +// Common request context keys used by the middlewares and api handlers. +const ( + ContextUserKey string = "user" + ContextAdminKey string = "admin" + ContextCollectionKey string = "collection" +) + +// RequireGuestOnly middleware requires a request to NOT have a valid +// Authorization header set. +// +// This middleware is the opposite of [apis.RequireAdminOrUserAuth()]. +func RequireGuestOnly() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := rest.NewBadRequestError("The request can be accessed only by guests.", nil) + + user, _ := c.Get(ContextUserKey).(*models.User) + if user != nil { + return err + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin != nil { + return err + } + + return next(c) + } + } +} + +// RequireUserAuth middleware requires a request to have +// a valid user Authorization header set (aka. `Authorization: User ...`). +func RequireUserAuth() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + user, _ := c.Get(ContextUserKey).(*models.User) + if user == nil { + return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil) + } + + return next(c) + } + } +} + +// RequireAdminAuth middleware requires a request to have +// a valid admin Authorization header set (aka. `Authorization: Admin ...`). +func RequireAdminAuth() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil { + return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil) + } + + return next(c) + } + } +} + +// RequireAdminOrUserAuth middleware requires a request to have +// a valid admin or user Authorization header set +// (aka. `Authorization: Admin ...` or `Authorization: User ...`). +// +// This middleware is the opposite of [apis.RequireGuestOnly()]. +func RequireAdminOrUserAuth() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + user, _ := c.Get(ContextUserKey).(*models.User) + + if admin == nil && user == nil { + return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil) + } + + return next(c) + } + } +} + +// RequireAdminOrOwnerAuth middleware requires a request to have +// a valid admin or user owner Authorization header set +// (aka. `Authorization: Admin ...` or `Authorization: User ...`). +// +// This middleware is similar to [apis.RequireAdminOrUserAuth()] but +// for the user token expects to have the same id as the path parameter +// `ownerIdParam` (default to "id"). +func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if ownerIdParam == "" { + ownerIdParam = "id" + } + + ownerId := c.PathParam(ownerIdParam) + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + loggedUser, _ := c.Get(ContextUserKey).(*models.User) + + if admin == nil && loggedUser == nil { + return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil) + } + + if admin == nil && loggedUser.Id != ownerId { + return rest.NewForbiddenError("You are not allowed to perform this request.", nil) + } + + return next(c) + } + } +} + +// LoadAuthContext middleware reads the Authorization request header +// and loads the token related user or admin instance into the +// request's context. +// +// This middleware is expected to be registered by default for all routes. +func LoadAuthContext(app core.App) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + token := c.Request().Header.Get("Authorization") + + if token != "" { + if strings.HasPrefix(token, "User ") { + user, err := app.Dao().FindUserByToken( + token[5:], + app.Settings().UserAuthToken.Secret, + ) + if err == nil && user != nil { + c.Set(ContextUserKey, user) + } + } else if strings.HasPrefix(token, "Admin ") { + admin, err := app.Dao().FindAdminByToken( + token[6:], + app.Settings().AdminAuthToken.Secret, + ) + if err == nil && admin != nil { + c.Set(ContextAdminKey, admin) + } + } + } + + return next(c) + } + } +} + +// LoadCollectionContext middleware finds the collection with related +// path identifier and loads it into the request context. +func LoadCollectionContext(app core.App) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if param := c.PathParam("collection"); param != "" { + collection, err := app.Dao().FindCollectionByNameOrId(param) + if err != nil || collection == nil { + return rest.NewNotFoundError("", err) + } + + c.Set(ContextCollectionKey, collection) + } + + return next(c) + } + } +} + +// ActivityLogger middleware takes care to save the request information +// into the logs database. +// +// The middleware does nothing if the app logs retention period is zero +// (aka. app.Settings().Logs.MaxDays = 0). +func ActivityLogger(app core.App) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + + // no logs retention + if app.Settings().Logs.MaxDays == 0 { + return err + } + + httpRequest := c.Request() + httpResponse := c.Response() + status := httpResponse.Status + meta := types.JsonMap{} + + if err != nil { + switch v := err.(type) { + case (*echo.HTTPError): + status = v.Code + meta["errorMessage"] = v.Message + meta["errorDetails"] = fmt.Sprint(v.Internal) + case (*rest.ApiError): + status = v.Code + meta["errorMessage"] = v.Message + meta["errorDetails"] = fmt.Sprint(v.RawData()) + default: + status = http.StatusBadRequest + meta["errorMessage"] = v.Error() + } + } + + requestAuth := models.RequestAuthGuest + if c.Get(ContextUserKey) != nil { + requestAuth = models.RequestAuthUser + } else if c.Get(ContextAdminKey) != nil { + requestAuth = models.RequestAuthAdmin + } + + model := &models.Request{ + Url: httpRequest.URL.RequestURI(), + Method: strings.ToLower(httpRequest.Method), + Status: status, + Auth: requestAuth, + Ip: httpRequest.RemoteAddr, + Referer: httpRequest.Referer(), + UserAgent: httpRequest.UserAgent(), + Meta: meta, + } + // set timestamp fields before firing a new go routine + model.RefreshCreated() + model.RefreshUpdated() + + routine.FireAndForget(func() { + attempts := 1 + + BeginSave: + logErr := app.LogsDao().SaveRequest(model) + if logErr != nil { + // try one more time after 10s in case of SQLITE_BUSY or "database is locked" error + if attempts <= 2 { + attempts++ + time.Sleep(10 * time.Second) + goto BeginSave + } else if app.IsDebug() { + log.Println("Log save failed:", logErr) + } + } + + // Delete old request logs + // --- + now := time.Now() + lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt")) + daysDiff := (now.Sub(lastLogsDeletedAt).Hours() * 24) + + if daysDiff > float64(app.Settings().Logs.MaxDays) { + deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays)) + if deleteErr == nil { + app.Cache().Set("lastLogsDeletedAt", now) + } else if app.IsDebug() { + log.Println("Logs delete failed:", deleteErr) + } + } + }) + + return err + } + } +} diff --git a/apis/middlewares_test.go b/apis/middlewares_test.go new file mode 100644 index 000000000..0f429a1d6 --- /dev/null +++ b/apis/middlewares_test.go @@ -0,0 +1,503 @@ +package apis_test + +import ( + "net/http" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/tests" +) + +func TestRequireGuestOnly(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + 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.RequireGuestOnly(), + }, + }) + }, + ExpectedStatus: 400, + 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.RequireGuestOnly(), + }, + }) + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expired/invalid token", + Method: http.MethodGet, + Url: "/my/test", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", + }, + 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.RequireGuestOnly(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + { + Name: "guest", + 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.RequireGuestOnly(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequireUserAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "guest", + 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.RequireUserAuth(), + }, + }) + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expired/invalid token", + Method: http.MethodGet, + Url: "/my/test", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", + }, + 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.RequireUserAuth(), + }, + }) + }, + 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.RequireUserAuth(), + }, + }) + }, + 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.RequireUserAuth(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequireAdminAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "guest", + 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.RequireAdminAuth(), + }, + }) + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + 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.RequireAdminAuth(), + }, + }) + }, + 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.RequireAdminAuth(), + }, + }) + }, + 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.RequireAdminAuth(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequireAdminOrUserAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "guest", + 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.RequireAdminOrUserAuth(), + }, + }) + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + 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.RequireAdminOrUserAuth(), + }, + }) + }, + 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.RequireAdminOrUserAuth(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + { + 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.RequireAdminOrUserAuth(), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRequireAdminOrOwnerAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "guest", + Method: http.MethodGet, + Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/my/test/:id", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrOwnerAuth(""), + }, + }) + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expired/invalid token", + Method: http.MethodGet, + Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/my/test/:id", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrOwnerAuth(""), + }, + }) + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "valid user token (different user)", + Method: http.MethodGet, + Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + e.AddRoute(echo.Route{ + Method: http.MethodGet, + Path: "/my/test/:id", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrOwnerAuth(""), + }, + }) + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "valid user token (owner)", + Method: http.MethodGet, + Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + 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/:id", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrOwnerAuth(""), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + { + Name: "valid admin token", + Method: http.MethodGet, + Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + 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/:custom", + Handler: func(c echo.Context) error { + return c.String(200, "test123") + }, + Middlewares: []echo.MiddlewareFunc{ + apis.RequireAdminOrOwnerAuth("custom"), + }, + }) + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test123"}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/realtime.go b/apis/realtime.go new file mode 100644 index 000000000..3a3901f53 --- /dev/null +++ b/apis/realtime.go @@ -0,0 +1,345 @@ +package apis + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/pocketbase/dbx" + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/resolvers" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +// BindRealtimeApi registers the realtime api endpoints. +func BindRealtimeApi(app core.App, rg *echo.Group) { + api := realtimeApi{app: app} + + subGroup := rg.Group("/realtime", ActivityLogger(app)) + subGroup.GET("", api.connect) + subGroup.POST("", api.setSubscriptions) + + api.bindEvents() +} + +type realtimeApi struct { + app core.App +} + +func (api *realtimeApi) connect(c echo.Context) error { + cancelCtx, cancelRequest := context.WithCancel(c.Request().Context()) + defer cancelRequest() + c.SetRequest(c.Request().Clone(cancelCtx)) + + // register new subscription client + client := subscriptions.NewDefaultClient() + api.app.SubscriptionsBroker().Register(client) + defer api.app.SubscriptionsBroker().Unregister(client.Id()) + + c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8") + c.Response().Header().Set("Cache-Control", "no-store") + c.Response().Header().Set("Connection", "keep-alive") + + event := &core.RealtimeConnectEvent{ + HttpContext: c, + Client: client, + } + + if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil { + return err + } + + // signalize established connection (aka. fire "connect" message) + fmt.Fprint(c.Response(), "id:"+client.Id()+"\n") + fmt.Fprint(c.Response(), "event:PB_CONNECT\n") + fmt.Fprint(c.Response(), "data:{\"clientId\":\""+client.Id()+"\"}\n\n") + c.Response().Flush() + + // start an idle timer to keep track of inactive/forgotten connections + idleDuration := 5 * time.Minute + idleTimer := time.NewTimer(idleDuration) + defer idleTimer.Stop() + + for { + select { + case <-idleTimer.C: + cancelRequest() + case msg, ok := <-client.Channel(): + if !ok { + // channel is closed + if api.app.IsDebug() { + log.Println("Realtime connection closed (closed channel):", client.Id()) + } + return nil + } + + w := c.Response() + fmt.Fprint(w, "id:"+client.Id()+"\n") + fmt.Fprint(w, "event:"+msg.Name+"\n") + fmt.Fprint(w, "data:"+msg.Data+"\n\n") + w.Flush() + + idleTimer.Stop() + idleTimer.Reset(idleDuration) + case <-c.Request().Context().Done(): + // connection is closed + if api.app.IsDebug() { + log.Println("Realtime connection closed (cancelled request):", client.Id()) + } + return nil + } + } +} + +// note: in case of reconnect, clients will have to resubmit all subscriptions again +func (api *realtimeApi) setSubscriptions(c echo.Context) error { + form := forms.NewRealtimeSubscribe() + + // read request data + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("", err) + } + + // validate request data + if err := form.Validate(); err != nil { + return rest.NewBadRequestError("", err) + } + + // find subscription client + client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId) + if err != nil { + return rest.NewNotFoundError("Missing or invalid client id.", err) + } + + // check if the previous request was authorized + oldAuthId := extractAuthIdFromGetter(client) + newAuthId := extractAuthIdFromGetter(c) + if oldAuthId != "" && oldAuthId != newAuthId { + return rest.NewForbiddenError("The current and the previous request authorization don't match.", nil) + } + + event := &core.RealtimeSubscribeEvent{ + HttpContext: c, + Client: client, + Subscriptions: form.Subscriptions, + } + + handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error { + // update auth state + e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey)) + e.Client.Set(ContextUserKey, e.HttpContext.Get(ContextUserKey)) + + // unsubscribe from any previous existing subscriptions + e.Client.Unsubscribe() + + // subscribe to the new subscriptions + e.Client.Subscribe(e.Subscriptions...) + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + + if handlerErr == nil { + api.app.OnRealtimeAfterSubscribeRequest().Trigger(event) + } + + return handlerErr +} + +func (api *realtimeApi) bindEvents() { + userTable := (&models.User{}).TableName() + adminTable := (&models.Admin{}).TableName() + + // update user/admin auth state + api.app.OnModelAfterUpdate().Add(func(data *core.ModelEvent) error { + modelTable := data.Model.TableName() + + var contextKey string + if modelTable == userTable { + contextKey = ContextUserKey + } else if modelTable == adminTable { + contextKey = ContextAdminKey + } else { + 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) + } + } + + return nil + }) + + // remove user/admin client(s) + api.app.OnModelAfterDelete().Add(func(data *core.ModelEvent) error { + modelTable := data.Model.TableName() + + var contextKey string + if modelTable == userTable { + contextKey = ContextUserKey + } else if modelTable == adminTable { + contextKey = ContextAdminKey + } else { + return nil + } + + for _, client := range api.app.SubscriptionsBroker().Clients() { + model, _ := client.Get(contextKey).(models.Model) + if model != nil && model.GetId() == data.Model.GetId() { + api.app.SubscriptionsBroker().Unregister(client.Id()) + } + } + + return nil + }) + + api.app.OnRecordAfterCreateRequest().Add(func(data *core.RecordCreateEvent) error { + api.broadcastRecord("create", data.Record) + return nil + }) + + api.app.OnRecordAfterUpdateRequest().Add(func(data *core.RecordUpdateEvent) error { + api.broadcastRecord("update", data.Record) + return nil + }) + + api.app.OnRecordAfterDeleteRequest().Add(func(data *core.RecordDeleteEvent) error { + api.broadcastRecord("delete", data.Record) + return nil + }) +} + +func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool { + admin, _ := client.Get(ContextAdminKey).(*models.Admin) + if admin != nil { + // admins can access everything + return true + } + + if accessRule == nil { + // only admins can access this record + return false + } + + ruleFunc := func(q *dbx.SelectQuery) error { + if *accessRule == "" { + return nil // empty public rule + } + + // emulate request data + requestData := map[string]any{ + "method": "get", + "query": map[string]any{}, + "data": map[string]any{}, + "user": nil, + } + user, _ := client.Get(ContextUserKey).(*models.User) + if user != nil { + requestData["user"], _ = user.AsMap() + } + + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData) + expr, err := search.FilterData(*accessRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + + return nil + } + + foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc) + if err == nil && foundRecord != nil { + return true + } + + return false +} + +type recordData struct { + Action string `json:"action"` + Record *models.Record `json:"record"` +} + +func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error { + collection := record.Collection() + if collection == nil { + return errors.New("Record collection not set.") + } + + clients := api.app.SubscriptionsBroker().Clients() + if len(clients) == 0 { + return nil // no subscribers + } + + subscriptionRuleMap := map[string]*string{ + (collection.Name + "/" + record.Id): collection.ViewRule, + (collection.Id + "/" + record.Id): collection.ViewRule, + collection.Name: collection.ListRule, + collection.Id: collection.ListRule, + } + + recordData := &recordData{ + Action: action, + Record: record, + } + + serializedData, err := json.Marshal(recordData) + if err != nil { + if api.app.IsDebug() { + log.Println(err) + } + return err + } + + for _, client := range clients { + for subscription, rule := range subscriptionRuleMap { + if !client.HasSubscription(subscription) { + continue + } + + if !api.canAccessRecord(client, record, rule) { + continue + } + + msg := subscriptions.Message{ + Name: subscription, + Data: string(serializedData), + } + + client.Channel() <- msg + } + } + + return nil +} + +type getter interface { + Get(string) any +} + +func extractAuthIdFromGetter(val getter) string { + user, _ := val.Get(ContextUserKey).(*models.User) + if user != nil { + return user.Id + } + + admin, _ := val.Get(ContextAdminKey).(*models.Admin) + if admin != nil { + return admin.Id + } + + return "" +} diff --git a/apis/realtime_test.go b/apis/realtime_test.go new file mode 100644 index 000000000..7dd9191fd --- /dev/null +++ b/apis/realtime_test.go @@ -0,0 +1,292 @@ +package apis_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +func TestRealtimeConnect(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Method: http.MethodGet, + Url: "/api/realtime", + ExpectedStatus: 200, + ExpectedContent: []string{ + `id:`, + `event:PB_CONNECT`, + `data:{"clientId":`, + }, + ExpectedEvents: map[string]int{ + "OnRealtimeConnectRequest": 1, + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if len(app.SubscriptionsBroker().Clients()) != 0 { + t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients())) + } + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRealtimeSubscribe(t *testing.T) { + client := subscriptions.NewDefaultClient() + + resetClient := func() { + client.Unsubscribe() + client.Set(apis.ContextAdminKey, nil) + client.Set(apis.ContextUserKey, nil) + } + + scenarios := []tests.ApiScenario{ + { + Name: "missing client", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"missing","subscriptions":["test1", "test2"]}`), + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "existing client - empty subscriptions", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":[]}`), + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRealtimeBeforeSubscribeRequest": 1, + "OnRealtimeAfterSubscribeRequest": 1, + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + client.Subscribe("test0") + app.SubscriptionsBroker().Register(client) + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + if len(client.Subscriptions()) != 0 { + t.Errorf("Expected no subscriptions, got %v", client.Subscriptions()) + } + resetClient() + }, + }, + { + Name: "existing client - 2 new subscriptions", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRealtimeBeforeSubscribeRequest": 1, + "OnRealtimeAfterSubscribeRequest": 1, + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + client.Subscribe("test0") + app.SubscriptionsBroker().Register(client) + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + expectedSubs := []string{"test1", "test2"} + if len(expectedSubs) != len(client.Subscriptions()) { + t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions()) + } + + for _, s := range expectedSubs { + if !client.HasSubscription(s) { + t.Errorf("Cannot find %q subscription in %v", s, client.Subscriptions()) + } + } + resetClient() + }, + }, + { + Name: "existing client - authorized admin", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRealtimeBeforeSubscribeRequest": 1, + "OnRealtimeAfterSubscribeRequest": 1, + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + app.SubscriptionsBroker().Register(client) + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) + if admin == nil { + t.Errorf("Expected admin auth model, got nil") + } + resetClient() + }, + }, + { + Name: "existing client - authorized user", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRealtimeBeforeSubscribeRequest": 1, + "OnRealtimeAfterSubscribeRequest": 1, + }, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + app.SubscriptionsBroker().Register(client) + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + user, _ := client.Get(apis.ContextUserKey).(*models.User) + if user == nil { + t.Errorf("Expected user auth model, got nil") + } + resetClient() + }, + }, + { + Name: "existing client - mismatched auth", + Method: http.MethodPost, + Url: "/api/realtime", + Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + initialAuth := &models.User{} + initialAuth.RefreshId() + client.Set(apis.ContextUserKey, initialAuth) + + app.SubscriptionsBroker().Register(client) + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + user, _ := client.Get(apis.ContextUserKey).(*models.User) + if user == nil { + t.Errorf("Expected user auth model, got nil") + } + resetClient() + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRealtimeUserDeleteEvent(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + apis.InitApi(testApp) + + user, err := testApp.Dao().FindUserByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + client := subscriptions.NewDefaultClient() + client.Set(apis.ContextUserKey, user) + testApp.SubscriptionsBroker().Register(client) + + testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user}) + + if len(testApp.SubscriptionsBroker().Clients()) != 0 { + t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) + } +} + +func TestRealtimeUserUpdateEvent(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + apis.InitApi(testApp) + + user1, err := testApp.Dao().FindUserByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + client := subscriptions.NewDefaultClient() + client.Set(apis.ContextUserKey, user1) + testApp.SubscriptionsBroker().Register(client) + + // refetch the user and change its email + user2, err := testApp.Dao().FindUserByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + user2.Email = "new@example.com" + + testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2}) + + clientUser, _ := client.Get(apis.ContextUserKey).(*models.User) + if clientUser.Email != user2.Email { + t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email) + } +} + +func TestRealtimeAdminDeleteEvent(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + apis.InitApi(testApp) + + admin, err := testApp.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + client := subscriptions.NewDefaultClient() + client.Set(apis.ContextAdminKey, admin) + testApp.SubscriptionsBroker().Register(client) + + testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin}) + + if len(testApp.SubscriptionsBroker().Clients()) != 0 { + t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients())) + } +} + +func TestRealtimeAdminUpdateEvent(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + apis.InitApi(testApp) + + admin1, err := testApp.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + client := subscriptions.NewDefaultClient() + client.Set(apis.ContextAdminKey, admin1) + testApp.SubscriptionsBroker().Register(client) + + // refetch the user and change its email + admin2, err := testApp.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + admin2.Email = "new@example.com" + + testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2}) + + clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin) + if clientAdmin.Email != admin2.Email { + t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.Email) + } +} diff --git a/apis/record.go b/apis/record.go new file mode 100644 index 000000000..1ac3e5088 --- /dev/null +++ b/apis/record.go @@ -0,0 +1,432 @@ +package apis + +import ( + "fmt" + "log" + "net/http" + "strings" + + "github.com/pocketbase/dbx" + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/resolvers" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/search" +) + +const expandQueryParam = "expand" + +// BindRecordApi registers the record api endpoints and the corresponding handlers. +func BindRecordApi(app core.App, rg *echo.Group) { + api := recordApi{app: app} + + subGroup := rg.Group( + "/collections/:collection/records", + ActivityLogger(app), + LoadCollectionContext(app), + ) + + subGroup.GET("", api.list) + subGroup.POST("", api.create) + subGroup.GET("/:id", api.view) + subGroup.PATCH("/:id", api.update) + subGroup.DELETE("/:id", api.delete) +} + +type recordApi struct { + app core.App +} + +func (api *recordApi) list(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", "Missing collection context.") + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil && collection.ListRule == nil { + // only admins can access if the rule is nil + return rest.NewForbiddenError("Only admins can perform this action.", nil) + } + + // forbid user/guest defined non-relational joins (aka. @collection.*) + queryStr := c.QueryString() + if admin == nil && queryStr != "" && (strings.Contains(queryStr, "@collection") || strings.Contains(queryStr, "%40collection")) { + return rest.NewForbiddenError("Only admins can filter by @collection.", nil) + } + + requestData := api.exportRequestData(c) + + fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData) + + searchProvider := search.NewProvider(fieldsResolver). + Query(api.app.Dao().RecordQuery(collection)) + + if admin == nil && collection.ListRule != nil { + searchProvider.AddFilter(search.FilterData(*collection.ListRule)) + } + + var rawRecords = []dbx.NullStringMap{} + result, err := searchProvider.ParseAndExec(queryStr, &rawRecords) + if err != nil { + return rest.NewBadRequestError("Invalid filter parameters.", err) + } + + records := models.NewRecordsFromNullStringMaps(collection, rawRecords) + + // expand records relations + expands := strings.Split(c.QueryParam(expandQueryParam), ",") + if len(expands) > 0 { + expandErr := api.app.Dao().ExpandRecords( + records, + expands, + api.expandFunc(c, requestData), + ) + if expandErr != nil && api.app.IsDebug() { + log.Println("Failed to expand relations: ", expandErr) + } + } + + result.Items = records + + event := &core.RecordsListEvent{ + HttpContext: c, + Collection: collection, + Records: records, + Result: result, + } + + return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Result) + }) +} + +func (api *recordApi) view(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", "Missing collection context.") + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil && collection.ViewRule == nil { + // only admins can access if the rule is nil + return rest.NewForbiddenError("Only admins can perform this action.", nil) + } + + recordId := c.PathParam("id") + if recordId == "" { + return rest.NewNotFoundError("", nil) + } + + requestData := api.exportRequestData(c) + + ruleFunc := func(q *dbx.SelectQuery) error { + if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" { + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData) + expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + } + return nil + } + + record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc) + if fetchErr != nil || record == nil { + return rest.NewNotFoundError("", fetchErr) + } + + expands := strings.Split(c.QueryParam(expandQueryParam), ",") + if len(expands) > 0 { + expandErr := api.app.Dao().ExpandRecord( + record, + expands, + api.expandFunc(c, requestData), + ) + if expandErr != nil && api.app.IsDebug() { + log.Println("Failed to expand relations: ", expandErr) + } + } + + event := &core.RecordViewEvent{ + HttpContext: c, + Record: record, + } + + return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Record) + }) +} + +func (api *recordApi) create(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", "Missing collection context.") + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil && collection.CreateRule == nil { + // only admins can access if the rule is nil + return rest.NewForbiddenError("Only admins can perform this action.", nil) + } + + requestData := api.exportRequestData(c) + + // temporary save the record and check it against the create rule + if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" { + ruleFunc := func(q *dbx.SelectQuery) error { + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData) + expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + return nil + } + + 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) + } + + testErr := testForm.DrySubmit(func(txDao *daos.Dao) error { + _, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc) + return fetchErr + }) + if testErr != nil { + return rest.NewBadRequestError("Failed to create record.", testErr) + } + } + + record := models.NewRecord(collection) + form := forms.NewRecordUpsert(api.app, record) + + // load request + if err := form.LoadData(c.Request()); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.RecordCreateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Record) + }) + + if handlerErr == nil { + api.app.OnRecordAfterCreateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *recordApi) update(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", "Missing collection context.") + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil && collection.UpdateRule == nil { + // only admins can access if the rule is nil + return rest.NewForbiddenError("Only admins can perform this action.", nil) + } + + recordId := c.PathParam("id") + if recordId == "" { + return rest.NewNotFoundError("", nil) + } + + requestData := api.exportRequestData(c) + + ruleFunc := func(q *dbx.SelectQuery) error { + if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" { + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData) + expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + } + return nil + } + + // fetch record + record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc) + if fetchErr != nil || record == nil { + return rest.NewNotFoundError("", fetchErr) + } + + form := forms.NewRecordUpsert(api.app, record) + + // load request + if err := form.LoadData(c.Request()); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.RecordUpdateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.Record) + }) + + if handlerErr == nil { + api.app.OnRecordAfterUpdateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *recordApi) delete(c echo.Context) error { + collection, _ := c.Get(ContextCollectionKey).(*models.Collection) + if collection == nil { + return rest.NewNotFoundError("", "Missing collection context.") + } + + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + if admin == nil && collection.DeleteRule == nil { + // only admins can access if the rule is nil + return rest.NewForbiddenError("Only admins can perform this action.", nil) + } + + recordId := c.PathParam("id") + if recordId == "" { + return rest.NewNotFoundError("", nil) + } + + requestData := api.exportRequestData(c) + + ruleFunc := func(q *dbx.SelectQuery) error { + if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" { + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData) + expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + } + return nil + } + + record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc) + if fetchErr != nil || record == nil { + return rest.NewNotFoundError("", fetchErr) + } + + event := &core.RecordDeleteEvent{ + HttpContext: c, + Record: record, + } + + handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error { + // delete the record + if err := api.app.Dao().DeleteRecord(e.Record); err != nil { + 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) + }) + + if handlerErr == nil { + api.app.OnRecordAfterDeleteRequest().Trigger(event) + } + + 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{} + bodyData := map[string]any{} + method := c.Request().Method + + echo.BindQueryParams(c, &queryParams) + + rest.BindBody(c, &bodyData) + + result["method"] = method + result["query"] = queryParams + result["data"] = bodyData + result["user"] = nil + + loggedUser, _ := c.Get(ContextUserKey).(*models.User) + if loggedUser != nil { + result["user"], _ = loggedUser.AsMap() + } + + return result +} + +func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc { + admin, _ := c.Get(ContextAdminKey).(*models.Admin) + + return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) { + return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error { + if admin != nil { + return nil // admin can access everything + } + + if relCollection.ViewRule == nil { + return fmt.Errorf("Only admins can view collection %q records", relCollection.Name) + } + + if *relCollection.ViewRule != "" { + resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), relCollection, requestData) + expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver) + if err != nil { + return err + } + resolver.UpdateQuery(q) + q.AndWhere(expr) + } + + return nil + }) + } +} diff --git a/apis/record_test.go b/apis/record_test.go new file mode 100644 index 000000000..00eab00ca --- /dev/null +++ b/apis/record_test.go @@ -0,0 +1,914 @@ +package apis_test + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/tests" +) + +func TestRecordsList(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodGet, + Url: "/api/collections/missing/records", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "unauthorized trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodGet, + Url: "/api/collections/demo/records", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodGet, + Url: "/api/collections/demo/records", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "public collection but with admin only filter/sort (aka. @collection)", + Method: http.MethodGet, + Url: "/api/collections/demo3/records?filter=@collection.demo.title='test'", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "public collection but with ENCODED admin only filter/sort (aka. @collection)", + Method: http.MethodGet, + Url: "/api/collections/demo3/records?filter=%40collection.demo.title%3D%27test%27", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodGet, + Url: "/api/collections/demo/records", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":3`, + `"items":[{`, + `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "public collection", + Method: http.MethodGet, + Url: "/api/collections/demo3/records", + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":1`, + `"items":[{`, + `"id":"2c542824-9de1-42fe-8924-e57c86267760"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "using the collection id as identifier", + Method: http.MethodGet, + Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89/records", + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":1`, + `"items":[{`, + `"id":"2c542824-9de1-42fe-8924-e57c86267760"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "valid query params", + Method: http.MethodGet, + Url: "/api/collections/demo/records?filter=title%7E%27test%27&sort=-title", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":2`, + `"items":[{`, + `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "invalid filter", + Method: http.MethodGet, + Url: "/api/collections/demo/records?filter=invalid~'test'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expand", + Method: http.MethodGet, + Url: "/api/collections/demo2/records?expand=manyrels,onerel&perPage=2&sort=created", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":2`, + `"totalItems":2`, + `"items":[{`, + `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + `"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`, + `"bool":true`, + `"number":456`, + `"user":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "authorized as user that DOESN'T match the collection list rule", + Method: http.MethodGet, + Url: "/api/collections/demo2/records", + RequestHeaders: map[string]string{ + // test@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":0`, + `"items":[]`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + { + Name: "authorized as user that matches the collection list rule", + Method: http.MethodGet, + Url: "/api/collections/demo2/records", + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":2`, + `"items":[{`, + `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`, + `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`, + }, + ExpectedEvents: map[string]int{"OnRecordsListRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRecordView(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodGet, + Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record (unauthorized)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "invalid record id (authorized)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/invalid", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record (authorized)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "mismatched collection-record pair (unauthorized)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "mismatched collection-record pair (authorized)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "unauthorized trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodGet, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "access record as admin", + Method: http.MethodGet, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"@collectionName":"demo"`, + `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + { + Name: "access record as admin (using the collection id as identifier)", + Method: http.MethodGet, + Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"@collectionName":"demo"`, + `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + { + Name: "access record as admin (test rule skipping)", + Method: http.MethodGet, + Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + `"@collectionName":"demo2"`, + `"id":"94568ca2-0bee-49d7-b749-06cb97956fd9"`, + `"manyrels":[]`, + `"onerel":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + { + Name: "access record as user (filter mismatch)", + Method: http.MethodGet, + Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9", + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "access record as user (filter match)", + Method: http.MethodGet, + Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f", + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + `"@collectionName":"demo2"`, + `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`, + `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`, + `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + { + Name: "expand relations", + Method: http.MethodGet, + Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f?expand=manyrels,onerel", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"@collectionId":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`, + `"@collectionName":"demo2"`, + `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`, + `"manyrels":[{`, + `"onerel":{`, + `"@collectionId":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`, + `"@collectionName":"demo"`, + `"id":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + `"id":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + }, + ExpectedEvents: map[string]int{"OnRecordViewRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRecordDelete(t *testing.T) { + ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) { + storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId) + + entries, _ := os.ReadDir(storageDir) + if len(entries) != 0 { + t.Errorf("Expected empty/deleted dir, found %d", len(entries)) + } + } + + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodDelete, + Url: "/api/collections/missing/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record (unauthorized)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record (authorized)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/00000000-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "mismatched collection-record pair (unauthorized)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "mismatched collection-record pair (authorized)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/63c2ab80-84ab-4057-a592-4604a731f78f", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "unauthorized trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user trying to access nil rule collection (aka. need admin auth)", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "access record as admin", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + "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 + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4") + }, + }, + { + Name: "access record as admin (using the collection id as identifier)", + Method: http.MethodDelete, + Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc/records/577bd676-aacb-4072-b7da-99d00ee210a4", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + "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 + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + ensureDeletedFiles(app, "3f2888f8-075d-49fe-9d09-ea7e951000dc", "577bd676-aacb-4072-b7da-99d00ee210a4") + }, + }, + { + Name: "deleting record as admin (test rule skipping)", + Method: http.MethodDelete, + Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + "OnRecordAfterDeleteRequest": 1, + "OnModelBeforeDelete": 1, + "OnModelAfterDelete": 1, + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "94568ca2-0bee-49d7-b749-06cb97956fd9") + }, + }, + { + Name: "deleting record as user (filter mismatch)", + Method: http.MethodDelete, + Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "deleting record as user (filter match)", + Method: http.MethodDelete, + Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + "OnRecordAfterDeleteRequest": 1, + "OnModelBeforeDelete": 1, + "OnModelAfterDelete": 1, + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + ensureDeletedFiles(app, "2c1010aa-b8fe-41d9-a980-99534ca8a167", "63c2ab80-84ab-4057-a592-4604a731f78f") + }, + }, + { + Name: "trying to delete record while being part of a non-cascade required relation", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/848a1dea-5ddd-42d6-a00d-030547bffcfe", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + }, + }, + { + Name: "cascade delete referenced records", + Method: http.MethodDelete, + Url: "/api/collections/demo/records/577bd676-aacb-4072-b7da-99d00ee210a4", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnRecordBeforeDeleteRequest": 1, + "OnRecordAfterDeleteRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeDelete": 2, + "OnModelAfterDelete": 2, + }, + AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + recId := "63c2ab80-84ab-4057-a592-4604a731f78f" + col, _ := app.Dao().FindCollectionByNameOrId("demo2") + rec, _ := app.Dao().FindRecordById(col, recId, nil) + if rec != nil { + 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", "63c2ab80-84ab-4057-a592-4604a731f78f") + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRecordCreate(t *testing.T) { + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "new", + }, "file") + if err != nil { + t.Fatal(err) + } + + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodPost, + Url: "/api/collections/missing/records", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "guest trying to access nil-rule collection", + Method: http.MethodPost, + Url: "/api/collections/demo/records", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "user trying to access nil-rule collection", + Method: http.MethodPost, + Url: "/api/collections/demo/records", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "submit invalid format", + Method: http.MethodPost, + Url: "/api/collections/demo3/records", + Body: strings.NewReader(`{"`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "submit nil body", + Method: http.MethodPost, + Url: "/api/collections/demo3/records", + Body: nil, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "guest submit in public collection", + Method: http.MethodPost, + Url: "/api/collections/demo3/records", + Body: strings.NewReader(`{"title":"new"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"title":"new"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeCreateRequest": 1, + "OnRecordAfterCreateRequest": 1, + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + }, + }, + { + Name: "user submit in restricted collection (rule failure check)", + 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" + }`), + RequestHeaders: map[string]string{ + // test@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "user submit in restricted collection (rule pass check)", + 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":true + }`), + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`, + `"text":"test123"`, + `"bool":true`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeCreateRequest": 1, + "OnRecordAfterCreateRequest": 1, + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + }, + }, + { + Name: "admin submit in restricted collection (rule skip check)", + 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 + }`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"onerel":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"manyrels":["577bd676-aacb-4072-b7da-99d00ee210a4"]`, + `"text":"test123"`, + `"bool":false`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeCreateRequest": 1, + "OnRecordAfterCreateRequest": 1, + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + }, + }, + { + Name: "submit via multipart form data", + Method: http.MethodPost, + Url: "/api/collections/demo/records", + Body: formData, + RequestHeaders: map[string]string{ + "Content-Type": mp.FormDataContentType(), + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"`, + `"title":"new"`, + `"file":"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeCreateRequest": 1, + "OnRecordAfterCreateRequest": 1, + "OnModelBeforeCreate": 1, + "OnModelAfterCreate": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestRecordUpdate(t *testing.T) { + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "new", + }, "file") + if err != nil { + t.Fatal(err) + } + + scenarios := []tests.ApiScenario{ + { + Name: "missing collection", + Method: http.MethodPatch, + Url: "/api/collections/missing/records/2c542824-9de1-42fe-8924-e57c86267760", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing record", + Method: http.MethodPatch, + Url: "/api/collections/demo3/records/00000000-9de1-42fe-8924-e57c86267760", + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "guest trying to edit nil-rule collection record", + Method: http.MethodPatch, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "user trying to edit nil-rule collection record", + Method: http.MethodPatch, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "submit invalid format", + Method: http.MethodPatch, + Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760", + Body: strings.NewReader(`{"`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "submit nil body", + Method: http.MethodPatch, + Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760", + Body: nil, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "guest submit in public collection", + Method: http.MethodPatch, + Url: "/api/collections/demo3/records/2c542824-9de1-42fe-8924-e57c86267760", + Body: strings.NewReader(`{"title":"new"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"2c542824-9de1-42fe-8924-e57c86267760"`, + `"title":"new"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeUpdateRequest": 1, + "OnRecordAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + { + Name: "user submit in restricted collection (rule failure check)", + Method: http.MethodPatch, + Url: "/api/collections/demo2/records/94568ca2-0bee-49d7-b749-06cb97956fd9", + Body: strings.NewReader(`{"text": "test_new"}`), + RequestHeaders: map[string]string{ + // test@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "user submit in restricted collection (rule pass check)", + Method: http.MethodPatch, + Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f", + Body: strings.NewReader(`{ + "text":"test_new", + "bool":false + }`), + RequestHeaders: map[string]string{ + // test3@example.com + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`, + `"rel_cascade":"577bd676-aacb-4072-b7da-99d00ee210a4"`, + `"onerel":"848a1dea-5ddd-42d6-a00d-030547bffcfe"`, + `"manyrels":["848a1dea-5ddd-42d6-a00d-030547bffcfe","577bd676-aacb-4072-b7da-99d00ee210a4"]`, + `"bool":false`, + `"text":"test_new"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeUpdateRequest": 1, + "OnRecordAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + { + Name: "admin submit in restricted collection (rule skip check)", + Method: http.MethodPatch, + Url: "/api/collections/demo2/records/63c2ab80-84ab-4057-a592-4604a731f78f", + Body: strings.NewReader(`{ + "text":"test_new" + }`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"63c2ab80-84ab-4057-a592-4604a731f78f"`, + `"text":"test_new"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeUpdateRequest": 1, + "OnRecordAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + { + Name: "submit via multipart form data", + Method: http.MethodPatch, + Url: "/api/collections/demo/records/b5c2ffc2-bafd-48f7-b8b7-090638afe209", + Body: formData, + RequestHeaders: map[string]string{ + "Content-Type": mp.FormDataContentType(), + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"b5c2ffc2-bafd-48f7-b8b7-090638afe209"`, + `"title":"new"`, + `"file":"`, + }, + ExpectedEvents: map[string]int{ + "OnRecordBeforeUpdateRequest": 1, + "OnRecordAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/settings.go b/apis/settings.go new file mode 100644 index 000000000..6cfd31553 --- /dev/null +++ b/apis/settings.go @@ -0,0 +1,71 @@ +package apis + +import ( + "net/http" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tools/rest" +) + +// BindSettingsApi registers the settings api endpoints. +func BindSettingsApi(app core.App, rg *echo.Group) { + api := settingsApi{app: app} + + subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth()) + subGroup.GET("", api.list) + subGroup.PATCH("", api.set) +} + +type settingsApi struct { + app core.App +} + +func (api *settingsApi) list(c echo.Context) error { + settings, err := api.app.Settings().RedactClone() + if err != nil { + return rest.NewBadRequestError("", err) + } + + event := &core.SettingsListEvent{ + HttpContext: c, + RedactedSettings: settings, + } + + return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings) + }) +} + +func (api *settingsApi) set(c echo.Context) error { + form := forms.NewSettingsUpsert(api.app) + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("An error occured while reading the submitted data.", err) + } + + event := &core.SettingsUpdateEvent{ + HttpContext: c, + OldSettings: api.app.Settings(), + 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) + } + + redactedSettings, err := api.app.Settings().RedactClone() + if err != nil { + return rest.NewBadRequestError("", err) + } + + return e.HttpContext.JSON(http.StatusOK, redactedSettings) + }) + + if handlerErr == nil { + api.app.OnSettingsAfterUpdateRequest().Trigger(event) + } + + return handlerErr +} diff --git a/apis/settings_test.go b/apis/settings_test.go new file mode 100644 index 000000000..afd6e65d1 --- /dev/null +++ b/apis/settings_test.go @@ -0,0 +1,188 @@ +package apis_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tests" +) + +func TestSettingsList(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/settings", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodGet, + Url: "/api/settings", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodGet, + Url: "/api/settings", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"meta":{`, + `"logs":{`, + `"smtp":{`, + `"s3":{`, + `"adminAuthToken":{`, + `"adminPasswordResetToken":{`, + `"userAuthToken":{`, + `"userPasswordResetToken":{`, + `"userEmailChangeToken":{`, + `"userVerificationToken":{`, + `"emailAuth":{`, + `"googleAuth":{`, + `"facebookAuth":{`, + `"githubAuth":{`, + `"gitlabAuth":{`, + `"secret":"******"`, + `"clientSecret":"******"`, + }, + ExpectedEvents: map[string]int{ + "OnSettingsListRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestSettingsSet(t *testing.T) { + validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}` + + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPatch, + Url: "/api/settings", + Body: strings.NewReader(validData), + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPatch, + Url: "/api/settings", + Body: strings.NewReader(validData), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin submitting empty data", + Method: http.MethodPatch, + Url: "/api/settings", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"meta":{`, + `"logs":{`, + `"smtp":{`, + `"s3":{`, + `"adminAuthToken":{`, + `"adminPasswordResetToken":{`, + `"userAuthToken":{`, + `"userPasswordResetToken":{`, + `"userEmailChangeToken":{`, + `"userVerificationToken":{`, + `"emailAuth":{`, + `"googleAuth":{`, + `"facebookAuth":{`, + `"githubAuth":{`, + `"gitlabAuth":{`, + `"secret":"******"`, + `"clientSecret":"******"`, + `"appName":"Acme"`, + `"minPasswordLength":8`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnSettingsBeforeUpdateRequest": 1, + "OnSettingsAfterUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin submitting invalid data", + Method: http.MethodPatch, + Url: "/api/settings", + Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"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", + Method: http.MethodPatch, + Url: "/api/settings", + Body: strings.NewReader(validData), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"meta":{`, + `"logs":{`, + `"smtp":{`, + `"s3":{`, + `"adminAuthToken":{`, + `"adminPasswordResetToken":{`, + `"userAuthToken":{`, + `"userPasswordResetToken":{`, + `"userEmailChangeToken":{`, + `"userVerificationToken":{`, + `"emailAuth":{`, + `"googleAuth":{`, + `"facebookAuth":{`, + `"githubAuth":{`, + `"gitlabAuth":{`, + `"secret":"******"`, + `"clientSecret":"******"`, + `"appName":"update_test"`, + `"minPasswordLength":12`, + }, + ExpectedEvents: map[string]int{ + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + "OnSettingsBeforeUpdateRequest": 1, + "OnSettingsAfterUpdateRequest": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/apis/user.go b/apis/user.go new file mode 100644 index 000000000..ea8f9347a --- /dev/null +++ b/apis/user.go @@ -0,0 +1,444 @@ +package apis + +import ( + "log" + "net/http" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tokens" + "github.com/pocketbase/pocketbase/tools/auth" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/routine" + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/security" + "golang.org/x/oauth2" +) + +// BindUserApi registers the user api endpoints and the corresponding handlers. +func BindUserApi(app core.App, rg *echo.Group) { + api := userApi{app: app} + + subGroup := rg.Group("/users", ActivityLogger(app)) + subGroup.GET("/auth-methods", api.authMethods) + subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly()) + subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly()) + subGroup.POST("/request-password-reset", api.requestPasswordReset) + subGroup.POST("/confirm-password-reset", api.confirmPasswordReset) + subGroup.POST("/request-verification", api.requestVerification) + subGroup.POST("/confirm-verification", api.confirmVerification) + subGroup.POST("/request-email-change", api.requestEmailChange, RequireUserAuth()) + subGroup.POST("/confirm-email-change", api.confirmEmailChange) + subGroup.POST("/refresh", api.refresh, RequireUserAuth()) + // crud + subGroup.GET("", api.list, RequireAdminAuth()) + subGroup.POST("", api.create) + subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id")) + subGroup.PATCH("/:id", api.update, RequireAdminAuth()) + subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id")) +} + +type userApi struct { + app core.App +} + +func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error { + token, tokenErr := tokens.NewUserAuthToken(api.app, user) + if tokenErr != nil { + return rest.NewBadRequestError("Failed to create auth token.", tokenErr) + } + + event := &core.UserAuthEvent{ + HttpContext: c, + User: user, + Token: token, + Meta: meta, + } + + return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error { + result := map[string]any{ + "token": e.Token, + "user": e.User, + } + + if e.Meta != nil { + result["meta"] = e.Meta + } + + return e.HttpContext.JSON(http.StatusOK, result) + }) +} + +func (api *userApi) refresh(c echo.Context) error { + user, _ := c.Get(ContextUserKey).(*models.User) + if user == nil { + return rest.NewNotFoundError("Missing auth user context.", nil) + } + + return api.authResponse(c, user, nil) +} + +type providerInfo struct { + Name string `json:"name"` + State string `json:"state"` + CodeVerifier string `json:"codeVerifier"` + CodeChallenge string `json:"codeChallenge"` + CodeChallengeMethod string `json:"codeChallengeMethod"` + AuthUrl string `json:"authUrl"` +} + +func (api *userApi) authMethods(c echo.Context) error { + result := struct { + EmailPassword bool `json:"emailPassword"` + AuthProviders []providerInfo `json:"authProviders"` + }{ + EmailPassword: true, + AuthProviders: []providerInfo{}, + } + + settings := api.app.Settings() + + result.EmailPassword = settings.EmailAuth.Enabled + + nameConfigMap := settings.NamedAuthProviderConfigs() + + for name, config := range nameConfigMap { + if !config.Enabled { + continue + } + + provider, err := auth.NewProviderByName(name) + if err != nil { + if api.app.IsDebug() { + log.Println(err) + } + + // skip provider + continue + } + + if err := config.SetupProvider(provider); err != nil { + if api.app.IsDebug() { + log.Println(err) + } + + // skip provider + continue + } + + state := security.RandomString(30) + codeVerifier := security.RandomString(30) + codeChallenge := security.S256Challenge(codeVerifier) + codeChallengeMethod := "S256" + result.AuthProviders = append(result.AuthProviders, providerInfo{ + Name: name, + State: state, + CodeVerifier: codeVerifier, + CodeChallenge: codeChallenge, + CodeChallengeMethod: codeChallengeMethod, + AuthUrl: provider.BuildAuthUrl( + state, + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod), + ) + "&redirect_uri=", // empty redirect_uri so that users can append their url + }) + } + + return c.JSON(http.StatusOK, result) +} + +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) + } + + user, authData, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to authenticated.", submitErr) + } + + return api.authResponse(c, user, authData) +} + +func (api *userApi) emailAuth(c echo.Context) error { + if !api.app.Settings().EmailAuth.Enabled { + return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil) + } + + form := forms.NewUserEmailLogin(api.app) + if readErr := c.Bind(form); readErr != nil { + return rest.NewBadRequestError("An error occured while reading the submitted data.", readErr) + } + + user, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to authenticate.", submitErr) + } + + return api.authResponse(c, user, nil) +} + +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) + } + + if err := form.Validate(); err != nil { + return rest.NewBadRequestError("An error occured while validating the form.", err) + } + + // run in background because we don't need to show + // the result to the user (prevents users enumeration) + routine.FireAndForget(func() { + if err := form.Submit(); err != nil && api.app.IsDebug() { + log.Println(err) + } + }) + + return c.NoContent(http.StatusNoContent) +} + +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) + } + + user, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to set new password.", submitErr) + } + + return api.authResponse(c, user, nil) +} + +func (api *userApi) requestEmailChange(c echo.Context) error { + loggedUser, _ := c.Get(ContextUserKey).(*models.User) + if loggedUser == nil { + return rest.NewUnauthorizedError("The request requires valid authorized user.", nil) + } + + 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) + } + + if err := form.Submit(); err != nil { + return rest.NewBadRequestError("Failed to request email change.", err) + } + + return c.NoContent(http.StatusNoContent) +} + +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) + } + + user, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("Failed to confirm email change.", submitErr) + } + + return api.authResponse(c, user, nil) +} + +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) + } + + if err := form.Validate(); err != nil { + return rest.NewBadRequestError("An error occured while validating the form.", err) + } + + // run in background because we don't need to show + // the result to the user (prevents users enumeration) + routine.FireAndForget(func() { + if err := form.Submit(); err != nil && api.app.IsDebug() { + log.Println(err) + } + }) + + return c.NoContent(http.StatusNoContent) +} + +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) + } + + user, submitErr := form.Submit() + if submitErr != nil { + return rest.NewBadRequestError("An error occured while submitting the form.", submitErr) + } + + return api.authResponse(c, user, nil) +} + +// ------------------------------------------------------------------- +// CRUD +// ------------------------------------------------------------------- + +func (api *userApi) list(c echo.Context) error { + fieldResolver := search.NewSimpleFieldResolver( + "id", "created", "updated", "email", "verified", + ) + + users := []*models.User{} + + result, searchErr := search.NewProvider(fieldResolver). + Query(api.app.Dao().UserQuery()). + ParseAndExec(c.QueryString(), &users) + if searchErr != nil { + return rest.NewBadRequestError("", searchErr) + } + + // eager load user profiles (if any) + if err := api.app.Dao().LoadProfiles(users); err != nil { + return rest.NewBadRequestError("", err) + } + + event := &core.UsersListEvent{ + HttpContext: c, + Users: users, + Result: result, + } + + return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.Result) + }) +} + +func (api *userApi) view(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + user, err := api.app.Dao().FindUserById(id) + if err != nil || user == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.UserViewEvent{ + HttpContext: c, + User: user, + } + + return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error { + return e.HttpContext.JSON(http.StatusOK, e.User) + }) +} + +func (api *userApi) create(c echo.Context) error { + if !api.app.Settings().EmailAuth.Enabled { + return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil) + } + + user := &models.User{} + form := forms.NewUserUpsert(api.app, user) + + // load request + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.UserCreateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.User) + }) + + if handlerErr == nil { + api.app.OnUserAfterCreateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *userApi) update(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + user, err := api.app.Dao().FindUserById(id) + if err != nil || user == nil { + return rest.NewNotFoundError("", err) + } + + form := forms.NewUserUpsert(api.app, user) + + // load request + if err := c.Bind(form); err != nil { + return rest.NewBadRequestError("Failed to read the submitted data due to invalid formatting.", err) + } + + event := &core.UserUpdateEvent{ + HttpContext: c, + 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) + } + + return e.HttpContext.JSON(http.StatusOK, e.User) + }) + + if handlerErr == nil { + api.app.OnUserAfterUpdateRequest().Trigger(event) + } + + return handlerErr +} + +func (api *userApi) delete(c echo.Context) error { + id := c.PathParam("id") + if id == "" { + return rest.NewNotFoundError("", nil) + } + + user, err := api.app.Dao().FindUserById(id) + if err != nil || user == nil { + return rest.NewNotFoundError("", err) + } + + event := &core.UserDeleteEvent{ + HttpContext: c, + User: user, + } + + handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error { + // delete the user model + if err := api.app.Dao().DeleteUser(e.User); err != nil { + return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + + if handlerErr == nil { + api.app.OnUserAfterDeleteRequest().Trigger(event) + } + + return handlerErr +} diff --git a/apis/user_test.go b/apis/user_test.go new file mode 100644 index 000000000..76dbd62b4 --- /dev/null +++ b/apis/user_test.go @@ -0,0 +1,900 @@ +package apis_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/tests" +) + +func TestUsersAuthMethods(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Method: http.MethodGet, + Url: "/api/users/auth-methods", + ExpectedStatus: 200, + ExpectedContent: []string{ + `"emailPassword":true`, + `"authProviders":[{`, + `"authProviders":[{`, + `"name":"gitlab"`, + `"state":`, + `"codeVerifier":`, + `"codeChallenge":`, + `"codeChallengeMethod":`, + `"authUrl":`, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserEmailAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "authorized as user", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "invalid body format", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + Body: strings.NewReader(`{"email`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + Body: strings.NewReader(`{"email":"","password":""}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"email":{`, + `"password":{`, + }, + }, + { + Name: "disabled email/pass auth with valid data", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + Body: strings.NewReader(`{"email":"test@example.com","password":"123456"}`), + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + app.Settings().EmailAuth.Enabled = false + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "valid data", + Method: http.MethodPost, + Url: "/api/users/auth-via-email", + Body: strings.NewReader(`{"email":"test2@example.com","password":"123456"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token"`, + `"user"`, + `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, + `"email":"test2@example.com"`, + `"verified":false`, // unverified user should be able to authenticate + }, + ExpectedEvents: map[string]int{"OnUserAuthRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserRequestPasswordReset(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/users/request-password-reset", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/users/request-password-reset", + Body: strings.NewReader(`{"email`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "missing user", + Method: http.MethodPost, + Url: "/api/users/request-password-reset", + Body: strings.NewReader(`{"email":"missing@example.com"}`), + ExpectedStatus: 204, + }, + { + Name: "existing user", + Method: http.MethodPost, + Url: "/api/users/request-password-reset", + Body: strings.NewReader(`{"email":"test@example.com"}`), + ExpectedStatus: 204, + // usually this events are fired but since the submit is + // executed in a separate go routine they are fired async + // ExpectedEvents: map[string]int{ + // "OnModelBeforeUpdate": 1, + // "OnModelAfterUpdate": 1, + // "OnMailerBeforeUserResetPasswordSend": 1, + // "OnMailerAfterUserResetPasswordSend": 1, + // }, + }, + { + Name: "existing user (after already sent)", + Method: http.MethodPost, + Url: "/api/users/request-password-reset", + Body: strings.NewReader(`{"email":"test@example.com"}`), + ExpectedStatus: 204, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserConfirmPasswordReset(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/users/confirm-password-reset", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"password":{"code":"validation_required","message":"Cannot be blank."},"passwordConfirm":{"code":"validation_required","message":"Cannot be blank."},"token":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + { + Name: "invalid data format", + Method: http.MethodPost, + Url: "/api/users/confirm-password-reset", + Body: strings.NewReader(`{"password`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "expired token", + Method: http.MethodPost, + Url: "/api/users/confirm-password-reset", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQxMDMxMjAwfQ.t2lVe0ny9XruQsSFQdXqBi0I85i6vIUAQjFXZY5HPxc","password":"123456789","passwordConfirm":"123456789"}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"token":{`, + `"code":"validation_invalid_token"`, + }, + }, + { + Name: "valid token and data", + Method: http.MethodPost, + Url: "/api/users/confirm-password-reset", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiNGQwMTk3Y2MtMmI0YS0zZjgzLWEyNmItZDc3YmM4NDIzZDNjIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTU2MDAwfQ.V1gEbY4caEIF6IhQAJ8KZD4RvOGvTCFuYg1fTRSvhe0","password":"123456789","passwordConfirm":"123456789"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":`, + `"user":`, + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + `"email":"test@example.com"`, + }, + ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserRequestVerification(t *testing.T) { + scenarios := []tests.ApiScenario{ + // empty data + { + Method: http.MethodPost, + Url: "/api/users/request-verification", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."}}`}, + }, + // invalid data + { + Method: http.MethodPost, + Url: "/api/users/request-verification", + Body: strings.NewReader(`{"email`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + // missing user + { + Method: http.MethodPost, + Url: "/api/users/request-verification", + Body: strings.NewReader(`{"email":"missing@example.com"}`), + ExpectedStatus: 204, + }, + // existing already verified user + { + Method: http.MethodPost, + Url: "/api/users/request-verification", + Body: strings.NewReader(`{"email":"test@example.com"}`), + ExpectedStatus: 204, + }, + // existing unverified user + { + Method: http.MethodPost, + Url: "/api/users/request-verification", + Body: strings.NewReader(`{"email":"test2@example.com"}`), + ExpectedStatus: 204, + // usually this events are fired but since the submit is + // executed in a separate go routine they are fired async + // ExpectedEvents: map[string]int{ + // "OnModelBeforeUpdate": 1, + // "OnModelAfterUpdate": 1, + // "OnMailerBeforeUserVerificationSend": 1, + // "OnMailerAfterUserVerificationSend": 1, + // }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserConfirmVerification(t *testing.T) { + scenarios := []tests.ApiScenario{ + // empty data + { + Method: http.MethodPost, + Url: "/api/users/confirm-verification", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":`, + `"token":{"code":"validation_required"`, + }, + }, + // invalid data + { + Method: http.MethodPost, + Url: "/api/users/confirm-verification", + Body: strings.NewReader(`{"token`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + // expired token + { + Method: http.MethodPost, + Url: "/api/users/confirm-verification", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTY0MTAzMTIwMH0.YCqyREksfqn7cWu-innNNTbWQCr9DgYr7dduM2wxrtQ"}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"token":{`, + `"code":"validation_invalid_token"`, + }, + }, + // valid token + { + Method: http.MethodPost, + Url: "/api/users/confirm-verification", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImlkIjoiN2JjODRkMjctNmJhMi1iNDJhLTM4M2YtNDE5N2NjM2QzZDBjIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTk1NjAwMH0.OsxRKuZrNTnwyVjvCwB4jY8TbT-NPZ-UFCpRhCvuv2U"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":`, + `"user":`, + `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, + `"email":"test2@example.com"`, + `"verified":true`, + }, + ExpectedEvents: map[string]int{ + "OnUserAuthRequest": 1, + "OnModelAfterUpdate": 1, + "OnModelBeforeUpdate": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserRequestEmailChange(t *testing.T) { + scenarios := []tests.ApiScenario{ + // unauthorized + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(`{"newEmail":"change@example.com"}`), + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as admin + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(`{"newEmail":"change@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // invalid data + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(`{"newEmail`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + // empty data + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":`, + `"newEmail":{"code":"validation_required"`, + }, + }, + // valid data (existing email) + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(`{"newEmail":"test2@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":`, + `"newEmail":{"code":"validation_user_email_exists"`, + }, + }, + // valid data (new email) + { + Method: http.MethodPost, + Url: "/api/users/request-email-change", + Body: strings.NewReader(`{"newEmail":"change@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnMailerBeforeUserChangeEmailSend": 1, + "OnMailerAfterUserChangeEmailSend": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserConfirmEmailChange(t *testing.T) { + scenarios := []tests.ApiScenario{ + // empty data + { + Method: http.MethodPost, + Url: "/api/users/confirm-email-change", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":`, + `"token":{"code":"validation_required"`, + `"password":{"code":"validation_required"`, + }, + }, + // invalid data + { + Method: http.MethodPost, + Url: "/api/users/confirm-email-change", + Body: strings.NewReader(`{"token`), + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + // expired token and correct password + { + Method: http.MethodPost, + Url: "/api/users/confirm-email-change", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjAwfQ.DOqNtSDcXbWix8OsK13X-tjfWi6jZNlAzIZiwG_YDOs","password":"123456"}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"token":{`, + `"code":"validation_invalid_token"`, + }, + }, + // valid token and incorrect password + { + Method: http.MethodPost, + Url: "/api/users/confirm-email-change", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"654321"}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"password":{`, + `"code":"validation_invalid_password"`, + }, + }, + // valid token and correct password + { + Method: http.MethodPost, + Url: "/api/users/confirm-email-change", + Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsIm5ld0VtYWlsIjoiY2hhbmdlQGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDAwfQ.aWMQJ_c49yFbzHO5TNhlkbKRokQ_isc2RbLGuSJx44c","password":"123456"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":`, + `"user":`, + `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, + `"email":"change@example.com"`, + `"verified":true`, + }, + ExpectedEvents: map[string]int{"OnUserAuthRequest": 1, "OnModelAfterUpdate": 1, "OnModelBeforeUpdate": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserRefresh(t *testing.T) { + scenarios := []tests.ApiScenario{ + // unauthorized + { + Method: http.MethodPost, + Url: "/api/users/refresh", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as admin + { + Method: http.MethodPost, + Url: "/api/users/refresh", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as user + { + Method: http.MethodPost, + Url: "/api/users/refresh", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"token":`, + `"user":`, + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + }, + ExpectedEvents: map[string]int{"OnUserAuthRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUsersList(t *testing.T) { + scenarios := []tests.ApiScenario{ + // unauthorized + { + Method: http.MethodGet, + Url: "/api/users", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as user + { + Method: http.MethodGet, + Url: "/api/users", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as admin + { + Method: http.MethodGet, + Url: "/api/users", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":3`, + `"items":[{`, + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + `"id":"7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"`, + `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`, + }, + ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, + }, + // authorized as admin + paging and sorting + { + Method: http.MethodGet, + Url: "/api/users?page=2&perPage=2&sort=-created", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":2`, + `"perPage":2`, + `"totalItems":3`, + `"items":[{`, + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + }, + ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, + }, + // authorized as admin + invalid filter + { + Method: http.MethodGet, + Url: "/api/users?filter=invalidfield~'test2'", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + // authorized as admin + valid filter + { + Method: http.MethodGet, + Url: "/api/users?filter=verified=true", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"page":1`, + `"perPage":30`, + `"totalItems":2`, + `"items":[{`, + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + `"id":"97cc3d3d-6ba2-383f-b42a-7bc84d27410c"`, + }, + ExpectedEvents: map[string]int{"OnUsersListRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserView(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodGet, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting user id", + Method: http.MethodGet, + Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + existing user id", + Method: http.MethodGet, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + }, + ExpectedEvents: map[string]int{"OnUserViewRequest": 1}, + }, + { + Name: "authorized as user - trying to view another user", + Method: http.MethodGet, + Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user - owner", + Method: http.MethodGet, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + }, + ExpectedEvents: map[string]int{"OnUserViewRequest": 1}, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserDelete(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodDelete, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + nonexisting user id", + Method: http.MethodDelete, + Url: "/api/users/00000000-0000-0000-0000-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + existing user id", + Method: http.MethodDelete, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnUserBeforeDeleteRequest": 1, + "OnUserAfterDeleteRequest": 1, + "OnModelBeforeDelete": 2, // cascade delete to related Record model + "OnModelAfterDelete": 2, // cascade delete to related Record model + }, + }, + { + Name: "authorized as user - trying to delete another user", + Method: http.MethodDelete, + Url: "/api/users/7bc84d27-6ba2-b42a-383f-4197cc3d3d0c", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 403, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user - owner", + Method: http.MethodDelete, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 204, + ExpectedEvents: map[string]int{ + "OnUserBeforeDeleteRequest": 1, + "OnUserAfterDeleteRequest": 1, + "OnModelBeforeDelete": 2, // cascade delete to related Record model + "OnModelAfterDelete": 2, // cascade delete to related Record model + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserCreate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "empty data", + Method: http.MethodPost, + Url: "/api/users", + Body: strings.NewReader(``), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"email":{"code":"validation_required"`, + `"password":{"code":"validation_required"`, + }, + ExpectedEvents: map[string]int{ + "OnUserBeforeCreateRequest": 1, + }, + }, + { + Name: "invalid data", + Method: http.MethodPost, + Url: "/api/users", + Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321"}`), + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"email":{"code":"validation_user_email_exists"`, + `"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", + Method: http.MethodPost, + Url: "/api/users", + Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`), + BeforeFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) { + app.Settings().EmailAuth.Enabled = false + }, + ExpectedStatus: 400, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "valid data", + Method: http.MethodPost, + Url: "/api/users", + Body: strings.NewReader(`{"email":"newuser@example.com","password":"123456789","passwordConfirm":"123456789"}`), + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":`, + `"email":"newuser@example.com"`, + }, + ExpectedEvents: map[string]int{ + "OnUserBeforeCreateRequest": 1, + "OnUserAfterCreateRequest": 1, + "OnModelBeforeCreate": 2, // +1 for the created profile record + "OnModelAfterCreate": 2, // +1 for the created profile record + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestUserUpdate(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPatch, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + Body: strings.NewReader(`{"email":"new@example.com"}`), + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user (owner)", + Method: http.MethodPatch, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + Body: strings.NewReader(`{"email":"new@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin - invalid/missing user id", + Method: http.MethodPatch, + Url: "/api/users/invalid", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 404, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin - empty data", + Method: http.MethodPatch, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + Body: strings.NewReader(``), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + `"email":"test@example.com"`, + }, + ExpectedEvents: map[string]int{ + "OnUserBeforeUpdateRequest": 1, + "OnUserAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + { + Name: "authorized as admin - invalid data", + Method: http.MethodPatch, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + Body: strings.NewReader(`{"email":"test2@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"email":{"code":"validation_user_email_exists"`, + }, + ExpectedEvents: map[string]int{ + "OnUserBeforeUpdateRequest": 1, + }, + }, + { + Name: "authorized as admin - valid data", + Method: http.MethodPatch, + Url: "/api/users/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + Body: strings.NewReader(`{"email":"new@example.com"}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 200, + ExpectedContent: []string{ + `"id":"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"`, + `"email":"new@example.com"`, + }, + ExpectedEvents: map[string]int{ + "OnUserBeforeUpdateRequest": 1, + "OnUserAfterUpdateRequest": 1, + "OnModelBeforeUpdate": 1, + "OnModelAfterUpdate": 1, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 000000000..dd35d453b --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "log" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/migrations/logs" + "github.com/pocketbase/pocketbase/tools/migrate" + "github.com/spf13/cobra" +) + +// NewMigrateCommand creates and returns new command for handling DB migrations. +func NewMigrateCommand(app core.App) *cobra.Command { + desc := ` +Supported arguments are: +- up - runs all available migrations. +- down [number] - reverts the last [number] applied migrations. +- create folder name - creates new migration template file. +` + var databaseFlag string + + command := &cobra.Command{ + Use: "migrate", + Short: "Executes DB migration scripts", + ValidArgs: []string{"up", "down", "create"}, + Long: desc, + Run: func(command *cobra.Command, args []string) { + // normalize + if databaseFlag != "logs" { + databaseFlag = "db" + } + + connections := migrationsConnectionsMap(app) + + runner, err := migrate.NewRunner( + connections[databaseFlag].DB, + connections[databaseFlag].MigrationsList, + ) + if err != nil { + log.Fatal(err) + } + + if err := runner.Run(args...); err != nil { + log.Fatal(err) + } + }, + } + + command.PersistentFlags().StringVar( + &databaseFlag, + "database", + "db", + "specify the database connection to use (db or logs)", + ) + + return command +} + +type migrationsConnection struct { + DB *dbx.DB + MigrationsList migrate.MigrationsList +} + +func migrationsConnectionsMap(app core.App) map[string]migrationsConnection { + return map[string]migrationsConnection{ + "db": { + DB: app.DB(), + MigrationsList: migrations.AppMigrations, + }, + "logs": { + DB: app.LogsDB(), + MigrationsList: logs.LogsMigrations, + }, + } +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 000000000..34699b723 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "crypto/tls" + "errors" + "fmt" + "log" + "net" + "net/http" + "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" + "golang.org/x/crypto/acme/autocert" +) + +// NewServeCommand creates and returns new command responsible for +// starting the default PocketBase web server. +func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command { + var allowedOrigins []string + var httpAddr string + var httpsAddr string + + command := &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) + } + + // reload app settings in case a new default value was set with a migration + // (or if this is the first time the init migration was executed) + if err := app.RefreshSettings(); err != nil { + color.Yellow("=====================================") + color.Yellow("WARNING - Settings load error! \n%v", err) + color.Yellow("Fallback to the application defaults.") + color.Yellow("=====================================") + } + + // if no admins are found, create the first one + totalAdmins, err := app.Dao().TotalAdmins() + if err != nil { + log.Fatalln(err) + return + } + if totalAdmins == 0 { + if err := promptCreateAdmin(app); err != nil { + log.Fatalln(err) + return + } + } + + // start http server + // --- + mainAddr := httpAddr + if httpsAddr != "" { + mainAddr = httpsAddr + } + + mainHost, _, _ := net.SplitHostPort(mainAddr) + + certManager := autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")), + HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost), + } + + serverConfig := &http.Server{ + TLSConfig: &tls.Config{ + GetCertificate: certManager.GetCertificate, + NextProtos: []string{acme.ALPNProto}, + }, + ReadTimeout: 60 * time.Second, + // WriteTimeout: 60 * time.Second, // breaks sse! + Handler: router, + Addr: mainAddr, + } + + if showStartBanner { + schema := "http" + if httpsAddr != "" { + schema = "https" + } + bold := color.New(color.Bold).Add(color.FgGreen) + bold.Printf("> Server started at: %s\n", color.CyanString("%s://%s", schema, serverConfig.Addr)) + fmt.Printf(" - REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr)) + fmt.Printf(" - Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr)) + } + + var serveErr error + if httpsAddr != "" { + // if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version + if httpAddr != "" { + go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil)) + } + + // start HTTPS server + serveErr = serverConfig.ListenAndServeTLS("", "") + } else { + // start HTTP server + serveErr = serverConfig.ListenAndServe() + } + + if serveErr != http.ErrServerClosed { + log.Fatalln(serveErr) + } + }, + } + + command.PersistentFlags().StringSliceVar( + &allowedOrigins, + "origins", + []string{"*"}, + "CORS allowed domain origins list", + ) + + command.PersistentFlags().StringVar( + &httpAddr, + "http", + "localhost:8090", + "api HTTP server address", + ) + + command.PersistentFlags().StringVar( + &httpsAddr, + "https", + "", + "api HTTPS server address (auto TLS via Let's Encrypt)\nthe incomming --http address traffic also will be redirected to this address", + ) + + return command +} + +func runMigrations(app core.App) error { + connections := migrationsConnectionsMap(app) + + for _, c := range connections { + runner, err := migrate.NewRunner(c.DB, c.MigrationsList) + if err != nil { + return err + } + + if _, err := runner.Up(); err != nil { + return err + } + } + + return nil +} + +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 +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 000000000..988179e48 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,21 @@ +// 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) + }, + } +} diff --git a/core/app.go b/core/app.go new file mode 100644 index 000000000..74990c2b1 --- /dev/null +++ b/core/app.go @@ -0,0 +1,424 @@ +// Package core is the backbone of PocketBase. +// +// It defines the main PocketBase App interface and its base implementation. +package core + +import ( + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/store" + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +// App defines the main PocketBase app interface. +type App interface { + // DB returns the default app database instance. + DB() *dbx.DB + + // Dao returns the default app Dao instance. + // + // This Dao could operate only on the tables and models + // associated with the default app database. For example, + // trying to access the request logs table will result in error. + Dao() *daos.Dao + + // LogsDB returns the app logs database instance. + LogsDB() *dbx.DB + + // LogsDao returns the app logs Dao instance. + // + // This Dao could operate only on the tables and models + // associated with the logs database. For example, trying to access + // the users table from LogsDao will result in error. + LogsDao() *daos.Dao + + // DataDir returns the app data directory path. + DataDir() string + + // EncryptionEnv returns the name of the app secret env key + // (used for settings encryption). + EncryptionEnv() string + + // IsDebug returns whether the app is in debug mode + // (showing more detailed error logs, executed sql statements, etc.). + IsDebug() bool + + // Settings returns the loaded app settings. + Settings() *Settings + + // Cache returns the app internal cache store. + Cache() *store.Store[any] + + // SubscriptionsBroker returns the app realtime subscriptions broker instance. + SubscriptionsBroker() *subscriptions.Broker + + // NewMailClient creates and returns a configured app mail client. + NewMailClient() mailer.Mailer + + // NewFilesystem creates and returns a configured filesystem.System instance. + // + // NB! Make sure to call `Close()` on the returned result + // after you are done working with it. + NewFilesystem() (*filesystem.System, error) + + // RefreshSettings reinitializes and reloads the stored application settings. + RefreshSettings() error + + // Bootstrap takes care for initializing the application + // (open db connections, load settings, etc.) + Bootstrap() error + + // ResetBootstrapState takes care for releasing initialized app resources + // (eg. closing db connections). + ResetBootstrapState() error + + // --------------------------------------------------------------- + // App event hooks + // --------------------------------------------------------------- + + // OnBeforeServe hook is triggered before serving the internal router (echo), + // allowing you to adjust its options and attach new routes. + OnBeforeServe() *hook.Hook[*ServeEvent] + + // --------------------------------------------------------------- + // Dao event hooks + // --------------------------------------------------------------- + + // OnModelBeforeCreate hook is triggered before inserting a new + // entry in the DB, allowing you to modify or validate the stored data. + OnModelBeforeCreate() *hook.Hook[*ModelEvent] + + // OnModelAfterCreate hook is triggered after successfuly + // inserting a new entry in the DB. + OnModelAfterCreate() *hook.Hook[*ModelEvent] + + // OnModelBeforeUpdate hook is triggered before updating existing + // entry in the DB, allowing you to modify or validate the stored data. + OnModelBeforeUpdate() *hook.Hook[*ModelEvent] + + // OnModelAfterUpdate hook is triggered after successfuly updating + // existing entry in the DB. + OnModelAfterUpdate() *hook.Hook[*ModelEvent] + + // OnModelBeforeDelete hook is triggered before deleting an + // existing entry from the DB. + OnModelBeforeDelete() *hook.Hook[*ModelEvent] + + // OnModelAfterDelete is triggered after successfuly deleting an + // existing entry from the DB. + OnModelAfterDelete() *hook.Hook[*ModelEvent] + + // --------------------------------------------------------------- + // Mailer event hooks + // --------------------------------------------------------------- + + // OnMailerBeforeAdminResetPasswordSend hook is triggered right before + // sending a password reset email to an admin. + // + // Could be used to send your own custom email template if + // hook.StopPropagation is returned in one of its listeners. + OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] + + // OnMailerAfterAdminResetPasswordSend hook is triggered after + // admin password reset email was successfuly sent. + OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] + + // OnMailerBeforeUserResetPasswordSend hook is triggered right before + // sending a password reset email to a user. + // + // Could be used to send your own custom email template if + // hook.StopPropagation is returned in one of its listeners. + OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] + + // OnMailerAfterUserResetPasswordSend hook is triggered after + // a user password reset email was successfuly sent. + OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] + + // OnMailerBeforeUserVerificationSend hook is triggered right before + // sending a verification email to a user. + // + // Could be used to send your own custom email template if + // hook.StopPropagation is returned in one of its listeners. + OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] + + // OnMailerAfterUserVerificationSend hook is triggered after a user + // verification email was successfuly sent. + OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] + + // OnMailerBeforeUserChangeEmailSend hook is triggered right before + // sending a confirmation new address email to a a user. + // + // Could be used to send your own custom email template if + // hook.StopPropagation is returned in one of its listeners. + OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] + + // OnMailerAfterUserChangeEmailSend hook is triggered after a user + // change address email was successfuly sent. + OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] + + // --------------------------------------------------------------- + // Realtime API event hooks + // --------------------------------------------------------------- + + // OnRealtimeConnectRequest hook is triggered right before establishing + // the SSE client connection. + OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] + + // OnRealtimeBeforeSubscribeRequest hook is triggered before changing + // the client subscriptions, allowing you to further validate and + // modify the submitted change. + OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] + + // OnRealtimeAfterSubscribeRequest hook is triggered after the client + // subscriptions were successfully changed. + OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] + + // --------------------------------------------------------------- + // Settings API event hooks + // --------------------------------------------------------------- + + // OnSettingsListRequest hook is triggered on each successfull + // API Settings list request. + // + // Could be used to validate or modify the response before + // returning it to the client. + OnSettingsListRequest() *hook.Hook[*SettingsListEvent] + + // OnSettingsBeforeUpdateRequest hook is triggered before each API + // Settings update request (after request data load and before settings persistence). + // + // Could be used to additionally validate the request data or + // implement completely different persistence behavior + // (returning hook.StopPropagation). + OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] + + // OnSettingsAfterUpdateRequest hook is triggered after each + // successful API Settings update request. + OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent] + + // --------------------------------------------------------------- + // File API event hooks + // --------------------------------------------------------------- + + // OnFileDownloadRequest hook is triggered before each API File download request. + // + // Could be used to validate or modify the file response before + // returning it to the client. + OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] + + // --------------------------------------------------------------- + // Admin API event hooks + // --------------------------------------------------------------- + + // OnAdminsListRequest hook is triggered on each API Admins list request. + // + // Could be used to validate or modify the response before returning it to the client. + OnAdminsListRequest() *hook.Hook[*AdminsListEvent] + + // OnAdminViewRequest hook is triggered on each API Admin view request. + // + // Could be used to validate or modify the response before returning it to the client. + OnAdminViewRequest() *hook.Hook[*AdminViewEvent] + + // OnAdminBeforeCreateRequest hook is triggered before each API + // Admin create request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] + + // OnAdminAfterCreateRequest hook is triggered after each + // successful API Admin create request. + OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent] + + // OnAdminBeforeUpdateRequest hook is triggered before each API + // Admin update request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] + + // OnAdminAfterUpdateRequest hook is triggered after each + // successful API Admin update request. + OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent] + + // OnAdminBeforeDeleteRequest hook is triggered before each API + // Admin delete request (after model load and before actual deletion). + // + // Could be used to additionally validate the request data or implement + // completely different delete behavior (returning hook.StopPropagation). + OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] + + // OnAdminAfterDeleteRequest hook is triggered after each + // successful API Admin delete request. + OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent] + + // OnAdminAuthRequest hook is triggered on each successful API Admin + // authentication request (sign-in, token refresh, etc.). + // + // Could be used to additionally validate or modify the + // authenticated admin data and token. + OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] + + // --------------------------------------------------------------- + // User API event hooks + // --------------------------------------------------------------- + + // OnUsersListRequest hook is triggered on each API Users list request. + // + // Could be used to validate or modify the response before returning it to the client. + OnUsersListRequest() *hook.Hook[*UsersListEvent] + + // OnUserViewRequest hook is triggered on each API User view request. + // + // Could be used to validate or modify the response before returning it to the client. + OnUserViewRequest() *hook.Hook[*UserViewEvent] + + // OnUserBeforeCreateRequest hook is triggered before each API User + // create request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] + + // OnUserAfterCreateRequest hook is triggered after each + // successful API User create request. + OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] + + // OnUserBeforeUpdateRequest hook is triggered before each API User + // update request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] + + // OnUserAfterUpdateRequest hook is triggered after each + // successful API User update request. + OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] + + // OnUserBeforeDeleteRequest hook is triggered before each API User + // delete request (after model load and before actual deletion). + // + // Could be used to additionally validate the request data or implement + // completely different delete behavior (returning hook.StopPropagation). + OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] + + // OnUserAfterDeleteRequest hook is triggered after each + // successful API User delete request. + OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] + + // OnUserAuthRequest hook is triggered on each successful API User + // authentication request (sign-in, token refresh, etc.). + // + // Could be used to additionally validate or modify the + // authenticated user data and token. + OnUserAuthRequest() *hook.Hook[*UserAuthEvent] + + // OnUserBeforeOauth2Register hook is triggered before each User OAuth2 + // authentication request (when the client config has enabled new users registration). + // + // Could be used to additionally validate or modify the new user + // before persisting in the DB. + OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] + + // OnUserAfterOauth2Register hook is triggered after each successful User + // OAuth2 authentication sign-up request (right after the new user persistence). + OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] + + // --------------------------------------------------------------- + // Record API event hooks + // --------------------------------------------------------------- + + // OnRecordsListRequest hook is triggered on each API Records list request. + // + // Could be used to validate or modify the response before returning it to the client. + OnRecordsListRequest() *hook.Hook[*RecordsListEvent] + + // OnRecordViewRequest hook is triggered on each API Record view request. + // + // Could be used to validate or modify the response before returning it to the client. + OnRecordViewRequest() *hook.Hook[*RecordViewEvent] + + // OnRecordBeforeCreateRequest hook is triggered before each API Record + // create request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] + + // OnRecordAfterCreateRequest hook is triggered after each + // successful API Record create request. + OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] + + // OnRecordBeforeUpdateRequest hook is triggered before each API Record + // update request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] + + // OnRecordAfterUpdateRequest hook is triggered after each + // successful API Record update request. + OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] + + // OnRecordBeforeDeleteRequest hook is triggered before each API Record + // delete request (after model load and before actual deletion). + // + // Could be used to additionally validate the request data or implement + // completely different delete behavior (returning hook.StopPropagation). + OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] + + // OnRecordAfterDeleteRequest hook is triggered after each + // successful API Record delete request. + OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] + + // --------------------------------------------------------------- + // Collection API event hooks + // --------------------------------------------------------------- + + // OnCollectionsListRequest hook is triggered on each API Collections list request. + // + // Could be used to validate or modify the response before returning it to the client. + OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent] + + // OnCollectionViewRequest hook is triggered on each API Collection view request. + // + // Could be used to validate or modify the response before returning it to the client. + OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent] + + // OnCollectionBeforeCreateRequest hook is triggered before each API Collection + // create request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] + + // OnCollectionAfterCreateRequest hook is triggered after each + // successful API Collection create request. + OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent] + + // OnCollectionBeforeUpdateRequest hook is triggered before each API Collection + // update request (after request data load and before model persistence). + // + // Could be used to additionally validate the request data or implement + // completely different persistence behavior (returning hook.StopPropagation). + OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] + + // OnCollectionAfterUpdateRequest hook is triggered after each + // successful API Collection update request. + OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent] + + // OnCollectionBeforeDeleteRequest hook is triggered before each API + // Collection delete request (after model load and before actual deletion). + // + // Could be used to additionally validate the request data or implement + // completely different delete behavior (returning hook.StopPropagation). + OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] + + // OnCollectionAfterDeleteRequest hook is triggered after each + // successful API Collection delete request. + OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] +} diff --git a/core/base.go b/core/base.go new file mode 100644 index 000000000..3fef55885 --- /dev/null +++ b/core/base.go @@ -0,0 +1,752 @@ +package core + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/fatih/color" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/store" + "github.com/pocketbase/pocketbase/tools/subscriptions" +) + +var _ App = (*BaseApp)(nil) + +// BaseApp implements core.App and defines the base PocketBase app structure. +type BaseApp struct { + // configurable parameters + isDebug bool + dataDir string + encryptionEnv string + + // internals + cache *store.Store[any] + settings *Settings + db *dbx.DB + dao *daos.Dao + logsDB *dbx.DB + logsDao *daos.Dao + subscriptionsBroker *subscriptions.Broker + + // serve event hooks + onBeforeServe *hook.Hook[*ServeEvent] + + // dao event hooks + onModelBeforeCreate *hook.Hook[*ModelEvent] + onModelAfterCreate *hook.Hook[*ModelEvent] + onModelBeforeUpdate *hook.Hook[*ModelEvent] + onModelAfterUpdate *hook.Hook[*ModelEvent] + onModelBeforeDelete *hook.Hook[*ModelEvent] + onModelAfterDelete *hook.Hook[*ModelEvent] + + // mailer event hooks + onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent] + onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent] + onMailerBeforeUserResetPasswordSend *hook.Hook[*MailerUserEvent] + onMailerAfterUserResetPasswordSend *hook.Hook[*MailerUserEvent] + onMailerBeforeUserVerificationSend *hook.Hook[*MailerUserEvent] + onMailerAfterUserVerificationSend *hook.Hook[*MailerUserEvent] + onMailerBeforeUserChangeEmailSend *hook.Hook[*MailerUserEvent] + onMailerAfterUserChangeEmailSend *hook.Hook[*MailerUserEvent] + + // realtime api event hooks + onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent] + onRealtimeBeforeSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent] + onRealtimeAfterSubscribeRequest *hook.Hook[*RealtimeSubscribeEvent] + + // settings api event hooks + onSettingsListRequest *hook.Hook[*SettingsListEvent] + onSettingsBeforeUpdateRequest *hook.Hook[*SettingsUpdateEvent] + onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent] + + // file api event hooks + onFileDownloadRequest *hook.Hook[*FileDownloadEvent] + + // admin api event hooks + onAdminsListRequest *hook.Hook[*AdminsListEvent] + onAdminViewRequest *hook.Hook[*AdminViewEvent] + onAdminBeforeCreateRequest *hook.Hook[*AdminCreateEvent] + onAdminAfterCreateRequest *hook.Hook[*AdminCreateEvent] + onAdminBeforeUpdateRequest *hook.Hook[*AdminUpdateEvent] + onAdminAfterUpdateRequest *hook.Hook[*AdminUpdateEvent] + onAdminBeforeDeleteRequest *hook.Hook[*AdminDeleteEvent] + onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent] + onAdminAuthRequest *hook.Hook[*AdminAuthEvent] + + // user api event hooks + onUsersListRequest *hook.Hook[*UsersListEvent] + onUserViewRequest *hook.Hook[*UserViewEvent] + onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent] + onUserAfterCreateRequest *hook.Hook[*UserCreateEvent] + onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent] + onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent] + onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent] + onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent] + onUserAuthRequest *hook.Hook[*UserAuthEvent] + onUserBeforeOauth2Register *hook.Hook[*UserOauth2RegisterEvent] + onUserAfterOauth2Register *hook.Hook[*UserOauth2RegisterEvent] + + // record api event hooks + onRecordsListRequest *hook.Hook[*RecordsListEvent] + onRecordViewRequest *hook.Hook[*RecordViewEvent] + onRecordBeforeCreateRequest *hook.Hook[*RecordCreateEvent] + onRecordAfterCreateRequest *hook.Hook[*RecordCreateEvent] + onRecordBeforeUpdateRequest *hook.Hook[*RecordUpdateEvent] + onRecordAfterUpdateRequest *hook.Hook[*RecordUpdateEvent] + onRecordBeforeDeleteRequest *hook.Hook[*RecordDeleteEvent] + onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent] + + // collection api event hooks + onCollectionsListRequest *hook.Hook[*CollectionsListEvent] + onCollectionViewRequest *hook.Hook[*CollectionViewEvent] + onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent] + onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent] + onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent] + onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent] + onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent] + onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent] +} + +// NewBaseApp creates and returns a new BaseApp instance +// configured with the provided arguments. +// +// To initialize the app, you need to call `app.Bootsrap()`. +func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { + return &BaseApp{ + dataDir: dataDir, + isDebug: isDebug, + encryptionEnv: encryptionEnv, + cache: store.New[any](nil), + settings: NewSettings(), + subscriptionsBroker: subscriptions.NewBroker(), + + // serve event hooks + onBeforeServe: &hook.Hook[*ServeEvent]{}, + + // dao event hooks + onModelBeforeCreate: &hook.Hook[*ModelEvent]{}, + onModelAfterCreate: &hook.Hook[*ModelEvent]{}, + onModelBeforeUpdate: &hook.Hook[*ModelEvent]{}, + onModelAfterUpdate: &hook.Hook[*ModelEvent]{}, + onModelBeforeDelete: &hook.Hook[*ModelEvent]{}, + onModelAfterDelete: &hook.Hook[*ModelEvent]{}, + + // mailer event hooks + onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{}, + onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{}, + onMailerBeforeUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{}, + onMailerAfterUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{}, + onMailerBeforeUserVerificationSend: &hook.Hook[*MailerUserEvent]{}, + onMailerAfterUserVerificationSend: &hook.Hook[*MailerUserEvent]{}, + onMailerBeforeUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{}, + onMailerAfterUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{}, + + // realtime API event hooks + onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{}, + onRealtimeBeforeSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{}, + onRealtimeAfterSubscribeRequest: &hook.Hook[*RealtimeSubscribeEvent]{}, + + // settings API event hooks + onSettingsListRequest: &hook.Hook[*SettingsListEvent]{}, + onSettingsBeforeUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{}, + onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{}, + + // file API event hooks + onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{}, + + // admin API event hooks + onAdminsListRequest: &hook.Hook[*AdminsListEvent]{}, + onAdminViewRequest: &hook.Hook[*AdminViewEvent]{}, + onAdminBeforeCreateRequest: &hook.Hook[*AdminCreateEvent]{}, + onAdminAfterCreateRequest: &hook.Hook[*AdminCreateEvent]{}, + onAdminBeforeUpdateRequest: &hook.Hook[*AdminUpdateEvent]{}, + onAdminAfterUpdateRequest: &hook.Hook[*AdminUpdateEvent]{}, + onAdminBeforeDeleteRequest: &hook.Hook[*AdminDeleteEvent]{}, + onAdminAfterDeleteRequest: &hook.Hook[*AdminDeleteEvent]{}, + onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{}, + + // user API event hooks + onUsersListRequest: &hook.Hook[*UsersListEvent]{}, + onUserViewRequest: &hook.Hook[*UserViewEvent]{}, + onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{}, + onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{}, + onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, + onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{}, + onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, + onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{}, + onUserAuthRequest: &hook.Hook[*UserAuthEvent]{}, + onUserBeforeOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{}, + onUserAfterOauth2Register: &hook.Hook[*UserOauth2RegisterEvent]{}, + + // record API event hooks + onRecordsListRequest: &hook.Hook[*RecordsListEvent]{}, + onRecordViewRequest: &hook.Hook[*RecordViewEvent]{}, + onRecordBeforeCreateRequest: &hook.Hook[*RecordCreateEvent]{}, + onRecordAfterCreateRequest: &hook.Hook[*RecordCreateEvent]{}, + onRecordBeforeUpdateRequest: &hook.Hook[*RecordUpdateEvent]{}, + onRecordAfterUpdateRequest: &hook.Hook[*RecordUpdateEvent]{}, + onRecordBeforeDeleteRequest: &hook.Hook[*RecordDeleteEvent]{}, + onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{}, + + // collection API event hooks + onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{}, + onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{}, + onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, + onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, + onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, + onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, + onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, + onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, + } +} + +// Bootstrap initializes the application +// (aka. create data dir, open db connections, load settings, etc.) +func (app *BaseApp) Bootstrap() error { + // clear resources of previous core state (if any) + if err := app.ResetBootstrapState(); err != nil { + return err + } + + // ensure that data dir exist + if err := os.MkdirAll(app.DataDir(), os.ModePerm); err != nil { + return err + } + + if err := app.initDataDB(); err != nil { + return err + } + + if err := app.initLogsDB(); err != nil { + return err + } + + // we don't check for an error because the db migrations may + // have not been executed yet. + app.RefreshSettings() + + return nil +} + +// ResetBootstrapState takes care for releasing initialized app resources +// (eg. closing db connections). +func (app *BaseApp) ResetBootstrapState() error { + if app.db != nil { + if err := app.db.Close(); err != nil { + return err + } + } + + if app.logsDB != nil { + if err := app.logsDB.Close(); err != nil { + return err + } + } + + app.dao = nil + app.logsDao = nil + app.settings = nil + + return nil +} + +// DB returns the default app database instance. +func (app *BaseApp) DB() *dbx.DB { + return app.db +} + +// Dao returns the default app Dao instance. +func (app *BaseApp) Dao() *daos.Dao { + return app.dao +} + +// LogsDB returns the app logs database instance. +func (app *BaseApp) LogsDB() *dbx.DB { + return app.logsDB +} + +// LogsDao returns the app logs Dao instance. +func (app *BaseApp) LogsDao() *daos.Dao { + return app.logsDao +} + +// DataDir returns the app data directory path. +func (app *BaseApp) DataDir() string { + return app.dataDir +} + +// EncryptionEnv returns the name of the app secret env key +// (used for settings encryption). +func (app *BaseApp) EncryptionEnv() string { + return app.encryptionEnv +} + +// IsDebug returns whether the app is in debug mode +// (showing more detailed error logs, executed sql statements, etc.). +func (app *BaseApp) IsDebug() bool { + return app.isDebug +} + +// Settings returns the loaded app settings. +func (app *BaseApp) Settings() *Settings { + return app.settings +} + +// Cache returns the app internal cache store. +func (app *BaseApp) Cache() *store.Store[any] { + return app.cache +} + +// SubscriptionsBroker returns the app realtime subscriptions broker instance. +func (app *BaseApp) SubscriptionsBroker() *subscriptions.Broker { + return app.subscriptionsBroker +} + +// NewMailClient creates and returns a new SMTP or Sendmail client +// based on the current app settings. +func (app *BaseApp) NewMailClient() mailer.Mailer { + if app.Settings().Smtp.Enabled { + return mailer.NewSmtpClient( + app.Settings().Smtp.Host, + app.Settings().Smtp.Port, + app.Settings().Smtp.Username, + app.Settings().Smtp.Password, + app.Settings().Smtp.Tls, + ) + } + + return &mailer.Sendmail{} +} + +// NewFilesystem creates a new local or S3 filesystem instance +// based on the current app settings. +// +// NB! Make sure to call `Close()` on the returned result +// after you are done working with it. +func (app *BaseApp) NewFilesystem() (*filesystem.System, error) { + if app.settings.S3.Enabled { + return filesystem.NewS3( + app.settings.S3.Bucket, + app.settings.S3.Region, + app.settings.S3.Endpoint, + app.settings.S3.AccessKey, + app.settings.S3.Secret, + ) + } + + // fallback to local filesystem + return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage")) +} + +// RefreshSettings reinitializes and reloads the stored application settings. +func (app *BaseApp) RefreshSettings() error { + if app.settings == nil { + app.settings = NewSettings() + } + + encryptionKey := os.Getenv(app.EncryptionEnv()) + + param, err := app.Dao().FindParamByKey(models.ParamAppSettings) + if err != nil && err != sql.ErrNoRows { + return err + } + + if param == nil { + // no settings were previously stored + return app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey) + } + + // load the settings from the stored param into the app ones + // --- + newSettings := NewSettings() + + // try first without decryption + plainDecodeErr := json.Unmarshal(param.Value, newSettings) + + // failed, try to decrypt + if plainDecodeErr != nil { + // load without decrypt has failed and there is no encryption key to use for decrypt + if encryptionKey == "" { + return errors.New("Failed to load the stored app settings (missing or invalid encryption key).") + } + + // decrypt + decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey) + if decryptErr != nil { + return decryptErr + } + + // decode again + decryptedDecodeErr := json.Unmarshal(decrypted, newSettings) + if decryptedDecodeErr != nil { + return decryptedDecodeErr + } + } + + if err := app.settings.Merge(newSettings); err != nil { + return err + } + + if plainDecodeErr == nil && encryptionKey != "" { + // save because previously the settings weren't stored encrypted + saveErr := app.Dao().SaveParam(models.ParamAppSettings, app.settings, encryptionKey) + if saveErr != nil { + return saveErr + } + } + + return nil +} + +// ------------------------------------------------------------------- +// Serve event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnBeforeServe() *hook.Hook[*ServeEvent] { + return app.onBeforeServe +} + +// ------------------------------------------------------------------- +// Dao event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnModelBeforeCreate() *hook.Hook[*ModelEvent] { + return app.onModelBeforeCreate +} + +func (app *BaseApp) OnModelAfterCreate() *hook.Hook[*ModelEvent] { + return app.onModelAfterCreate +} + +func (app *BaseApp) OnModelBeforeUpdate() *hook.Hook[*ModelEvent] { + return app.onModelBeforeUpdate +} + +func (app *BaseApp) OnModelAfterUpdate() *hook.Hook[*ModelEvent] { + return app.onModelAfterUpdate +} + +func (app *BaseApp) OnModelBeforeDelete() *hook.Hook[*ModelEvent] { + return app.onModelBeforeDelete +} + +func (app *BaseApp) OnModelAfterDelete() *hook.Hook[*ModelEvent] { + return app.onModelAfterDelete +} + +// ------------------------------------------------------------------- +// Mailer event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] { + return app.onMailerBeforeAdminResetPasswordSend +} + +func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] { + return app.onMailerAfterAdminResetPasswordSend +} + +func (app *BaseApp) OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerBeforeUserResetPasswordSend +} + +func (app *BaseApp) OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerAfterUserResetPasswordSend +} + +func (app *BaseApp) OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerBeforeUserVerificationSend +} + +func (app *BaseApp) OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerAfterUserVerificationSend +} + +func (app *BaseApp) OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerBeforeUserChangeEmailSend +} + +func (app *BaseApp) OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] { + return app.onMailerAfterUserChangeEmailSend +} + +// ------------------------------------------------------------------- +// Realtime API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent] { + return app.onRealtimeConnectRequest +} + +func (app *BaseApp) OnRealtimeBeforeSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] { + return app.onRealtimeBeforeSubscribeRequest +} + +func (app *BaseApp) OnRealtimeAfterSubscribeRequest() *hook.Hook[*RealtimeSubscribeEvent] { + return app.onRealtimeAfterSubscribeRequest +} + +// ------------------------------------------------------------------- +// Settings API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnSettingsListRequest() *hook.Hook[*SettingsListEvent] { + return app.onSettingsListRequest +} + +func (app *BaseApp) OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] { + return app.onSettingsBeforeUpdateRequest +} + +func (app *BaseApp) OnSettingsAfterUpdateRequest() *hook.Hook[*SettingsUpdateEvent] { + return app.onSettingsAfterUpdateRequest +} + +// ------------------------------------------------------------------- +// File API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent] { + return app.onFileDownloadRequest +} + +// ------------------------------------------------------------------- +// Admin API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnAdminsListRequest() *hook.Hook[*AdminsListEvent] { + return app.onAdminsListRequest +} + +func (app *BaseApp) OnAdminViewRequest() *hook.Hook[*AdminViewEvent] { + return app.onAdminViewRequest +} + +func (app *BaseApp) OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] { + return app.onAdminBeforeCreateRequest +} + +func (app *BaseApp) OnAdminAfterCreateRequest() *hook.Hook[*AdminCreateEvent] { + return app.onAdminAfterCreateRequest +} + +func (app *BaseApp) OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] { + return app.onAdminBeforeUpdateRequest +} + +func (app *BaseApp) OnAdminAfterUpdateRequest() *hook.Hook[*AdminUpdateEvent] { + return app.onAdminAfterUpdateRequest +} + +func (app *BaseApp) OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] { + return app.onAdminBeforeDeleteRequest +} + +func (app *BaseApp) OnAdminAfterDeleteRequest() *hook.Hook[*AdminDeleteEvent] { + return app.onAdminAfterDeleteRequest +} + +func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] { + return app.onAdminAuthRequest +} + +// ------------------------------------------------------------------- +// User API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnUsersListRequest() *hook.Hook[*UsersListEvent] { + return app.onUsersListRequest +} + +func (app *BaseApp) OnUserViewRequest() *hook.Hook[*UserViewEvent] { + return app.onUserViewRequest +} + +func (app *BaseApp) OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] { + return app.onUserBeforeCreateRequest +} + +func (app *BaseApp) OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] { + return app.onUserAfterCreateRequest +} + +func (app *BaseApp) OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] { + return app.onUserBeforeUpdateRequest +} + +func (app *BaseApp) OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] { + return app.onUserAfterUpdateRequest +} + +func (app *BaseApp) OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] { + return app.onUserBeforeDeleteRequest +} + +func (app *BaseApp) OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] { + return app.onUserAfterDeleteRequest +} + +func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] { + return app.onUserAuthRequest +} + +func (app *BaseApp) OnUserBeforeOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] { + return app.onUserBeforeOauth2Register +} + +func (app *BaseApp) OnUserAfterOauth2Register() *hook.Hook[*UserOauth2RegisterEvent] { + return app.onUserAfterOauth2Register +} + +// ------------------------------------------------------------------- +// Record API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnRecordsListRequest() *hook.Hook[*RecordsListEvent] { + return app.onRecordsListRequest +} + +func (app *BaseApp) OnRecordViewRequest() *hook.Hook[*RecordViewEvent] { + return app.onRecordViewRequest +} + +func (app *BaseApp) OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] { + return app.onRecordBeforeCreateRequest +} + +func (app *BaseApp) OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent] { + return app.onRecordAfterCreateRequest +} + +func (app *BaseApp) OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] { + return app.onRecordBeforeUpdateRequest +} + +func (app *BaseApp) OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent] { + return app.onRecordAfterUpdateRequest +} + +func (app *BaseApp) OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] { + return app.onRecordBeforeDeleteRequest +} + +func (app *BaseApp) OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent] { + return app.onRecordAfterDeleteRequest +} + +// ------------------------------------------------------------------- +// Collection API event hooks +// ------------------------------------------------------------------- + +func (app *BaseApp) OnCollectionsListRequest() *hook.Hook[*CollectionsListEvent] { + return app.onCollectionsListRequest +} + +func (app *BaseApp) OnCollectionViewRequest() *hook.Hook[*CollectionViewEvent] { + return app.onCollectionViewRequest +} + +func (app *BaseApp) OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] { + return app.onCollectionBeforeCreateRequest +} + +func (app *BaseApp) OnCollectionAfterCreateRequest() *hook.Hook[*CollectionCreateEvent] { + return app.onCollectionAfterCreateRequest +} + +func (app *BaseApp) OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] { + return app.onCollectionBeforeUpdateRequest +} + +func (app *BaseApp) OnCollectionAfterUpdateRequest() *hook.Hook[*CollectionUpdateEvent] { + return app.onCollectionAfterUpdateRequest +} + +func (app *BaseApp) OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] { + return app.onCollectionBeforeDeleteRequest +} + +func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] { + return app.onCollectionAfterDeleteRequest +} + +// ------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------- + +func (app *BaseApp) initLogsDB() error { + var connectErr error + app.logsDB, connectErr = connectDB(filepath.Join(app.DataDir(), "logs.db")) + if connectErr != nil { + return connectErr + } + + app.logsDao = app.createDao(app.logsDB) + + return nil +} + +func (app *BaseApp) initDataDB() error { + var connectErr error + app.db, connectErr = connectDB(filepath.Join(app.DataDir(), "data.db")) + if connectErr != nil { + return connectErr + } + + app.db.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) { + if app.IsDebug() { + color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) + } + } + + app.db.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) { + if app.IsDebug() { + color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql) + } + } + + app.dao = app.createDao(app.db) + + return nil +} + +func (app *BaseApp) createDao(db dbx.Builder) *daos.Dao { + dao := daos.New(db) + + dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { + return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m}) + } + + dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) { + app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m}) + } + + dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { + return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m}) + } + + dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) { + app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m}) + } + + dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { + return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m}) + } + + dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) { + app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m}) + } + + return dao +} diff --git a/core/base_test.go b/core/base_test.go new file mode 100644 index 000000000..0a589fdad --- /dev/null +++ b/core/base_test.go @@ -0,0 +1,438 @@ +package core + +import ( + "os" + "testing" + + "github.com/pocketbase/pocketbase/tools/mailer" +) + +func TestNewBaseApp(t *testing.T) { + const testDataDir = "./pb_base_app_test_data_dir/" + defer os.RemoveAll(testDataDir) + + app := NewBaseApp(testDataDir, "test_env", true) + + if app.dataDir != testDataDir { + t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir) + } + + if app.encryptionEnv != "test_env" { + t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir) + } + + if !app.isDebug { + t.Fatalf("expected isDebug true, got %v", app.isDebug) + } + + if app.cache == nil { + t.Fatal("expected cache to be set, got nil") + } + + if app.settings == nil { + t.Fatal("expected settings to be set, got nil") + } + + if app.subscriptionsBroker == nil { + t.Fatal("expected subscriptionsBroker to be set, got nil") + } +} + +func TestBaseAppBootstrap(t *testing.T) { + const testDataDir = "./pb_base_app_test_data_dir/" + defer os.RemoveAll(testDataDir) + + app := NewBaseApp(testDataDir, "pb_test_env", false) + defer app.ResetBootstrapState() + + // bootstrap + if err := app.Bootstrap(); err != nil { + t.Fatal(err) + } + + if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() { + t.Fatal("Expected test data directory to be created.") + } + + if app.dao == nil { + t.Fatal("Expected app.dao to be initialized, got nil.") + } + + if app.dao.BeforeCreateFunc == nil { + t.Fatal("Expected app.dao.BeforeCreateFunc to be set, got nil.") + } + + if app.dao.AfterCreateFunc == nil { + t.Fatal("Expected app.dao.AfterCreateFunc to be set, got nil.") + } + + if app.dao.BeforeUpdateFunc == nil { + t.Fatal("Expected app.dao.BeforeUpdateFunc to be set, got nil.") + } + + if app.dao.AfterUpdateFunc == nil { + t.Fatal("Expected app.dao.AfterUpdateFunc to be set, got nil.") + } + + if app.dao.BeforeDeleteFunc == nil { + t.Fatal("Expected app.dao.BeforeDeleteFunc to be set, got nil.") + } + + if app.dao.AfterDeleteFunc == nil { + t.Fatal("Expected app.dao.AfterDeleteFunc to be set, got nil.") + } + + if app.logsDao == nil { + t.Fatal("Expected app.logsDao to be initialized, got nil.") + } + + if app.settings == nil { + t.Fatal("Expected app.settings to be initialized, got nil.") + } + + // reset + if err := app.ResetBootstrapState(); err != nil { + t.Fatal(err) + } + + if app.dao != nil { + t.Fatalf("Expected app.dao to be nil, got %v.", app.dao) + } + + if app.logsDao != nil { + t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao) + } + + if app.settings != nil { + t.Fatalf("Expected app.settings to be nil, got %v.", app.settings) + } +} + +func TestBaseAppGetters(t *testing.T) { + const testDataDir = "./pb_base_app_test_data_dir/" + defer os.RemoveAll(testDataDir) + + app := NewBaseApp(testDataDir, "pb_test_env", false) + defer app.ResetBootstrapState() + + if err := app.Bootstrap(); err != nil { + t.Fatal(err) + } + + if app.db != app.DB() { + t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.db) + } + + if app.dao != app.Dao() { + t.Fatalf("Expected app.Dao %v, got %v", app.Dao(), app.dao) + } + + if app.logsDB != app.LogsDB() { + t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDB) + } + + if app.logsDao != app.LogsDao() { + t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao) + } + + if app.dataDir != app.DataDir() { + t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir) + } + + if app.encryptionEnv != app.EncryptionEnv() { + t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv) + } + + if app.isDebug != app.IsDebug() { + t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug) + } + + if app.settings != app.Settings() { + t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings) + } + + if app.cache != app.Cache() { + t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache) + } + + if app.subscriptionsBroker != app.SubscriptionsBroker() { + t.Fatalf("Expected app.SubscriptionsBroker %v, got %v", app.SubscriptionsBroker(), app.subscriptionsBroker) + } + + if app.onBeforeServe != app.OnBeforeServe() || app.OnBeforeServe() == nil { + t.Fatalf("Getter app.OnBeforeServe does not match or nil (%v vs %v)", app.OnBeforeServe(), app.onBeforeServe) + } + + if app.onModelBeforeCreate != app.OnModelBeforeCreate() || app.OnModelBeforeCreate() == nil { + t.Fatalf("Getter app.OnModelBeforeCreate does not match or nil (%v vs %v)", app.OnModelBeforeCreate(), app.onModelBeforeCreate) + } + + if app.onModelAfterCreate != app.OnModelAfterCreate() || app.OnModelAfterCreate() == nil { + t.Fatalf("Getter app.OnModelAfterCreate does not match or nil (%v vs %v)", app.OnModelAfterCreate(), app.onModelAfterCreate) + } + + if app.onModelBeforeUpdate != app.OnModelBeforeUpdate() || app.OnModelBeforeUpdate() == nil { + t.Fatalf("Getter app.OnModelBeforeUpdate does not match or nil (%v vs %v)", app.OnModelBeforeUpdate(), app.onModelBeforeUpdate) + } + + if app.onModelAfterUpdate != app.OnModelAfterUpdate() || app.OnModelAfterUpdate() == nil { + t.Fatalf("Getter app.OnModelAfterUpdate does not match or nil (%v vs %v)", app.OnModelAfterUpdate(), app.onModelAfterUpdate) + } + + if app.onModelBeforeDelete != app.OnModelBeforeDelete() || app.OnModelBeforeDelete() == nil { + t.Fatalf("Getter app.OnModelBeforeDelete does not match or nil (%v vs %v)", app.OnModelBeforeDelete(), app.onModelBeforeDelete) + } + + if app.onModelAfterDelete != app.OnModelAfterDelete() || app.OnModelAfterDelete() == nil { + t.Fatalf("Getter app.OnModelAfterDelete does not match or nil (%v vs %v)", app.OnModelAfterDelete(), app.onModelAfterDelete) + } + + if app.onMailerBeforeAdminResetPasswordSend != app.OnMailerBeforeAdminResetPasswordSend() || app.OnMailerBeforeAdminResetPasswordSend() == nil { + t.Fatalf("Getter app.OnMailerBeforeAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeAdminResetPasswordSend(), app.onMailerBeforeAdminResetPasswordSend) + } + + if app.onMailerAfterAdminResetPasswordSend != app.OnMailerAfterAdminResetPasswordSend() || app.OnMailerAfterAdminResetPasswordSend() == nil { + t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend) + } + + if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil { + t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend) + } + + if app.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil { + t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend) + } + + if app.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil { + t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend) + } + + if app.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil { + t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend) + } + + if app.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil { + t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend) + } + + if app.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil { + t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend) + } + + if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil { + t.Fatalf("Getter app.OnRealtimeConnectRequest does not match or nil (%v vs %v)", app.OnRealtimeConnectRequest(), app.onRealtimeConnectRequest) + } + + if app.onRealtimeBeforeSubscribeRequest != app.OnRealtimeBeforeSubscribeRequest() || app.OnRealtimeBeforeSubscribeRequest() == nil { + t.Fatalf("Getter app.OnRealtimeBeforeSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeBeforeSubscribeRequest(), app.onRealtimeBeforeSubscribeRequest) + } + + if app.onRealtimeAfterSubscribeRequest != app.OnRealtimeAfterSubscribeRequest() || app.OnRealtimeAfterSubscribeRequest() == nil { + t.Fatalf("Getter app.OnRealtimeAfterSubscribeRequest does not match or nil (%v vs %v)", app.OnRealtimeAfterSubscribeRequest(), app.onRealtimeAfterSubscribeRequest) + } + + if app.onSettingsListRequest != app.OnSettingsListRequest() || app.OnSettingsListRequest() == nil { + t.Fatalf("Getter app.OnSettingsListRequest does not match or nil (%v vs %v)", app.OnSettingsListRequest(), app.onSettingsListRequest) + } + + if app.onSettingsBeforeUpdateRequest != app.OnSettingsBeforeUpdateRequest() || app.OnSettingsBeforeUpdateRequest() == nil { + t.Fatalf("Getter app.OnSettingsBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsBeforeUpdateRequest(), app.onSettingsBeforeUpdateRequest) + } + + if app.onSettingsAfterUpdateRequest != app.OnSettingsAfterUpdateRequest() || app.OnSettingsAfterUpdateRequest() == nil { + t.Fatalf("Getter app.OnSettingsAfterUpdateRequest does not match or nil (%v vs %v)", app.OnSettingsAfterUpdateRequest(), app.onSettingsAfterUpdateRequest) + } + + if app.onFileDownloadRequest != app.OnFileDownloadRequest() || app.OnFileDownloadRequest() == nil { + t.Fatalf("Getter app.OnFileDownloadRequest does not match or nil (%v vs %v)", app.OnFileDownloadRequest(), app.onFileDownloadRequest) + } + + if app.onAdminsListRequest != app.OnAdminsListRequest() || app.OnAdminsListRequest() == nil { + t.Fatalf("Getter app.OnAdminsListRequest does not match or nil (%v vs %v)", app.OnAdminsListRequest(), app.onAdminsListRequest) + } + + if app.onAdminViewRequest != app.OnAdminViewRequest() || app.OnAdminViewRequest() == nil { + t.Fatalf("Getter app.OnAdminViewRequest does not match or nil (%v vs %v)", app.OnAdminViewRequest(), app.onAdminViewRequest) + } + + if app.onAdminBeforeCreateRequest != app.OnAdminBeforeCreateRequest() || app.OnAdminBeforeCreateRequest() == nil { + t.Fatalf("Getter app.OnAdminBeforeCreateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeCreateRequest(), app.onAdminBeforeCreateRequest) + } + + if app.onAdminAfterCreateRequest != app.OnAdminAfterCreateRequest() || app.OnAdminAfterCreateRequest() == nil { + t.Fatalf("Getter app.OnAdminAfterCreateRequest does not match or nil (%v vs %v)", app.OnAdminAfterCreateRequest(), app.onAdminAfterCreateRequest) + } + + if app.onAdminBeforeUpdateRequest != app.OnAdminBeforeUpdateRequest() || app.OnAdminBeforeUpdateRequest() == nil { + t.Fatalf("Getter app.OnAdminBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnAdminBeforeUpdateRequest(), app.onAdminBeforeUpdateRequest) + } + + if app.onAdminAfterUpdateRequest != app.OnAdminAfterUpdateRequest() || app.OnAdminAfterUpdateRequest() == nil { + t.Fatalf("Getter app.OnAdminAfterUpdateRequest does not match or nil (%v vs %v)", app.OnAdminAfterUpdateRequest(), app.onAdminAfterUpdateRequest) + } + + if app.onAdminBeforeDeleteRequest != app.OnAdminBeforeDeleteRequest() || app.OnAdminBeforeDeleteRequest() == nil { + t.Fatalf("Getter app.OnAdminBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnAdminBeforeDeleteRequest(), app.onAdminBeforeDeleteRequest) + } + + if app.onAdminAfterDeleteRequest != app.OnAdminAfterDeleteRequest() || app.OnAdminAfterDeleteRequest() == nil { + t.Fatalf("Getter app.OnAdminAfterDeleteRequest does not match or nil (%v vs %v)", app.OnAdminAfterDeleteRequest(), app.onAdminAfterDeleteRequest) + } + + if app.onAdminAuthRequest != app.OnAdminAuthRequest() || app.OnAdminAuthRequest() == nil { + t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest) + } + + if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil { + t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest) + } + + if app.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil { + t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest) + } + + if app.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil { + t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest) + } + + if app.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil { + t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest) + } + + if app.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil { + t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest) + } + + if app.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil { + t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest) + } + + if app.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil { + t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest) + } + + if app.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil { + t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest) + } + + if app.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil { + t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest) + } + + if app.onUserBeforeOauth2Register != app.OnUserBeforeOauth2Register() || app.OnUserBeforeOauth2Register() == nil { + t.Fatalf("Getter app.OnUserBeforeOauth2Register does not match or nil (%v vs %v)", app.OnUserBeforeOauth2Register(), app.onUserBeforeOauth2Register) + } + + if app.onUserAfterOauth2Register != app.OnUserAfterOauth2Register() || app.OnUserAfterOauth2Register() == nil { + t.Fatalf("Getter app.OnUserAfterOauth2Register does not match or nil (%v vs %v)", app.OnUserAfterOauth2Register(), app.onUserAfterOauth2Register) + } + + if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil { + t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest) + } + + if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil { + t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest) + } + + if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil { + t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest) + } + + if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil { + t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest) + } + + if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil { + t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest) + } + + if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil { + t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest) + } + + if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil { + t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest) + } + + if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil { + t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest) + } + + if app.onCollectionsListRequest != app.OnCollectionsListRequest() || app.OnCollectionsListRequest() == nil { + t.Fatalf("Getter app.OnCollectionsListRequest does not match or nil (%v vs %v)", app.OnCollectionsListRequest(), app.onCollectionsListRequest) + } + + if app.onCollectionViewRequest != app.OnCollectionViewRequest() || app.OnCollectionViewRequest() == nil { + t.Fatalf("Getter app.OnCollectionViewRequest does not match or nil (%v vs %v)", app.OnCollectionViewRequest(), app.onCollectionViewRequest) + } + + if app.onCollectionBeforeCreateRequest != app.OnCollectionBeforeCreateRequest() || app.OnCollectionBeforeCreateRequest() == nil { + t.Fatalf("Getter app.OnCollectionBeforeCreateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeCreateRequest(), app.onCollectionBeforeCreateRequest) + } + + if app.onCollectionAfterCreateRequest != app.OnCollectionAfterCreateRequest() || app.OnCollectionAfterCreateRequest() == nil { + t.Fatalf("Getter app.OnCollectionAfterCreateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterCreateRequest(), app.onCollectionAfterCreateRequest) + } + + if app.onCollectionBeforeUpdateRequest != app.OnCollectionBeforeUpdateRequest() || app.OnCollectionBeforeUpdateRequest() == nil { + t.Fatalf("Getter app.OnCollectionBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeUpdateRequest(), app.onCollectionBeforeUpdateRequest) + } + + if app.onCollectionAfterUpdateRequest != app.OnCollectionAfterUpdateRequest() || app.OnCollectionAfterUpdateRequest() == nil { + t.Fatalf("Getter app.OnCollectionAfterUpdateRequest does not match or nil (%v vs %v)", app.OnCollectionAfterUpdateRequest(), app.onCollectionAfterUpdateRequest) + } + + if app.onCollectionBeforeDeleteRequest != app.OnCollectionBeforeDeleteRequest() || app.OnCollectionBeforeDeleteRequest() == nil { + t.Fatalf("Getter app.OnCollectionBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionBeforeDeleteRequest(), app.onCollectionBeforeDeleteRequest) + } + + if app.onCollectionAfterDeleteRequest != app.OnCollectionAfterDeleteRequest() || app.OnCollectionAfterDeleteRequest() == nil { + t.Fatalf("Getter app.OnCollectionAfterDeleteRequest does not match or nil (%v vs %v)", app.OnCollectionAfterDeleteRequest(), app.onCollectionAfterDeleteRequest) + } +} + +func TestBaseAppNewMailClient(t *testing.T) { + const testDataDir = "./pb_base_app_test_data_dir/" + defer os.RemoveAll(testDataDir) + + app := NewBaseApp(testDataDir, "pb_test_env", false) + + client1 := app.NewMailClient() + if val, ok := client1.(*mailer.Sendmail); !ok { + t.Fatalf("Expected mailer.Sendmail instance, got %v", val) + } + + app.Settings().Smtp.Enabled = true + + client2 := app.NewMailClient() + if val, ok := client2.(*mailer.SmtpClient); !ok { + t.Fatalf("Expected mailer.SmtpClient instance, got %v", val) + } +} + +func TestBaseAppNewFilesystem(t *testing.T) { + const testDataDir = "./pb_base_app_test_data_dir/" + defer os.RemoveAll(testDataDir) + + app := NewBaseApp(testDataDir, "pb_test_env", false) + + // local + local, localErr := app.NewFilesystem() + if localErr != nil { + t.Fatal(localErr) + } + if local == nil { + t.Fatal("Expected local filesystem instance, got nil") + } + + // misconfigured s3 + app.Settings().S3.Enabled = true + s3, s3Err := app.NewFilesystem() + if s3Err == nil { + t.Fatal("Expected S3 error, got nil") + } + if s3 != nil { + t.Fatalf("Expected nil s3 filesystem, got %v", s3) + } +} diff --git a/core/db_cgo.go b/core/db_cgo.go new file mode 100644 index 000000000..4ff773664 --- /dev/null +++ b/core/db_cgo.go @@ -0,0 +1,26 @@ +//go:build cgo + +package core + +import ( + "fmt" + + "github.com/pocketbase/dbx" + _ "github.com/mattn/go-sqlite3" +) + +func connectDB(dbPath string) (*dbx.DB, error) { + pragmas := "_foreign_keys=1&_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=8000" + + db, openErr := dbx.MustOpen("sqlite3", fmt.Sprintf("%s?%s", dbPath, pragmas)) + if openErr != nil { + return nil, openErr + } + + // additional pragmas not supported through the dsn string + _, err := db.NewQuery(` + pragma journal_size_limit = 100000000; + `).Execute() + + return db, err +} diff --git a/core/db_nocgo.go b/core/db_nocgo.go new file mode 100644 index 000000000..63939c4f5 --- /dev/null +++ b/core/db_nocgo.go @@ -0,0 +1,16 @@ +//go:build !cgo + +package core + +import ( + "fmt" + + "github.com/pocketbase/dbx" + _ "modernc.org/sqlite" +) + +func connectDB(dbPath string) (*dbx.DB, error) { + pragmas := "_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(8000)&_pragma=journal_size_limit(100000000)" + + return dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas)) +} diff --git a/core/events.go b/core/events.go new file mode 100644 index 000000000..5c3a854e4 --- /dev/null +++ b/core/events.go @@ -0,0 +1,230 @@ +package core + +import ( + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/auth" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/search" + "github.com/pocketbase/pocketbase/tools/subscriptions" + + "github.com/labstack/echo/v5" +) + +// ------------------------------------------------------------------- +// Serve events data +// ------------------------------------------------------------------- + +type ServeEvent struct { + App App + Router *echo.Echo +} + +// ------------------------------------------------------------------- +// Model DAO events data +// ------------------------------------------------------------------- + +type ModelEvent struct { + Dao *daos.Dao + Model models.Model +} + +// ------------------------------------------------------------------- +// Mailer events data +// ------------------------------------------------------------------- + +type MailerUserEvent struct { + MailClient mailer.Mailer + User *models.User + Meta map[string]any +} + +type MailerAdminEvent struct { + MailClient mailer.Mailer + Admin *models.Admin + Meta map[string]any +} + +// ------------------------------------------------------------------- +// Realtime API events data +// ------------------------------------------------------------------- + +type RealtimeConnectEvent struct { + HttpContext echo.Context + Client subscriptions.Client +} + +type RealtimeSubscribeEvent struct { + HttpContext echo.Context + Client subscriptions.Client + Subscriptions []string +} + +// ------------------------------------------------------------------- +// Settings API events data +// ------------------------------------------------------------------- + +type SettingsListEvent struct { + HttpContext echo.Context + RedactedSettings *Settings +} + +type SettingsUpdateEvent struct { + HttpContext echo.Context + OldSettings *Settings + NewSettings *Settings +} + +// ------------------------------------------------------------------- +// Record API events data +// ------------------------------------------------------------------- + +type RecordsListEvent struct { + HttpContext echo.Context + Collection *models.Collection + Records []*models.Record + Result *search.Result +} + +type RecordViewEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordCreateEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordUpdateEvent struct { + HttpContext echo.Context + Record *models.Record +} + +type RecordDeleteEvent struct { + HttpContext echo.Context + Record *models.Record +} + +// ------------------------------------------------------------------- +// Admin API events data +// ------------------------------------------------------------------- + +type AdminsListEvent struct { + HttpContext echo.Context + Admins []*models.Admin + Result *search.Result +} + +type AdminViewEvent struct { + HttpContext echo.Context + Admin *models.Admin +} + +type AdminCreateEvent struct { + HttpContext echo.Context + Admin *models.Admin +} + +type AdminUpdateEvent struct { + HttpContext echo.Context + Admin *models.Admin +} + +type AdminDeleteEvent struct { + HttpContext echo.Context + Admin *models.Admin +} + +type AdminAuthEvent struct { + HttpContext echo.Context + Admin *models.Admin + Token string +} + +// ------------------------------------------------------------------- +// User API events data +// ------------------------------------------------------------------- + +type UsersListEvent struct { + HttpContext echo.Context + Users []*models.User + Result *search.Result +} + +type UserViewEvent struct { + HttpContext echo.Context + User *models.User +} + +type UserCreateEvent struct { + HttpContext echo.Context + User *models.User +} + +type UserUpdateEvent struct { + HttpContext echo.Context + User *models.User +} + +type UserDeleteEvent struct { + HttpContext echo.Context + User *models.User +} + +type UserAuthEvent struct { + HttpContext echo.Context + User *models.User + Token string + Meta any +} + +type UserOauth2RegisterEvent struct { + HttpContext echo.Context + User *models.User + AuthData *auth.AuthUser +} + +// ------------------------------------------------------------------- +// Collection API events data +// ------------------------------------------------------------------- + +type CollectionsListEvent struct { + HttpContext echo.Context + Collections []*models.Collection + Result *search.Result +} + +type CollectionViewEvent struct { + HttpContext echo.Context + Collection *models.Collection +} + +type CollectionCreateEvent struct { + HttpContext echo.Context + Collection *models.Collection +} + +type CollectionUpdateEvent struct { + HttpContext echo.Context + Collection *models.Collection +} + +type CollectionDeleteEvent struct { + HttpContext echo.Context + Collection *models.Collection +} + +// ------------------------------------------------------------------- +// File API events data +// ------------------------------------------------------------------- + +type FileDownloadEvent struct { + HttpContext echo.Context + Collection *models.Collection + Record *models.Record + FileField *schema.SchemaField + ServedPath string + ServedName string +} diff --git a/core/settings.go b/core/settings.go new file mode 100644 index 000000000..881e207a1 --- /dev/null +++ b/core/settings.go @@ -0,0 +1,412 @@ +package core + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/tools/auth" + "github.com/pocketbase/pocketbase/tools/security" +) + +// Common settings placeholder tokens +const ( + EmailPlaceholderAppUrl string = "%APP_URL%" + EmailPlaceholderToken string = "%TOKEN%" +) + +// Settings defines common app configuration options. +type Settings struct { + mux sync.RWMutex + + Meta MetaConfig `form:"meta" json:"meta"` + Logs LogsConfig `form:"logs" json:"logs"` + Smtp SmtpConfig `form:"smtp" json:"smtp"` + S3 S3Config `form:"s3" json:"s3"` + AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"` + AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"` + UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"` + UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"` + UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"` + UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"` + EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"` + GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"` + FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"` + GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"` + GitlabAuth AuthProviderConfig `form:"gitlabAuth" json:"gitlabAuth"` +} + +// NewSettings creates and returns a new default Settings instance. +func NewSettings() *Settings { + return &Settings{ + Meta: MetaConfig{ + AppName: "Acme", + AppUrl: "http://localhost:8090", + SenderName: "Support", + SenderAddress: "support@example.com", + UserVerificationUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-verification/" + EmailPlaceholderToken, + UserResetPasswordUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-password-reset/" + EmailPlaceholderToken, + UserConfirmEmailChangeUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-email-change/" + EmailPlaceholderToken, + }, + Logs: LogsConfig{ + MaxDays: 7, + }, + Smtp: SmtpConfig{ + Enabled: false, + Host: "smtp.example.com", + Port: 587, + Username: "", + Password: "", + Tls: false, + }, + AdminAuthToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 1209600, // 14 days, + }, + AdminPasswordResetToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 1800, // 30 minutes, + }, + UserAuthToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 1209600, // 14 days, + }, + UserPasswordResetToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 1800, // 30 minutes, + }, + UserVerificationToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 604800, // 7 days, + }, + UserEmailChangeToken: TokenConfig{ + Secret: security.RandomString(50), + Duration: 1800, // 30 minutes, + }, + EmailAuth: EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 8, + }, + GoogleAuth: AuthProviderConfig{ + Enabled: false, + AllowRegistrations: true, + }, + FacebookAuth: AuthProviderConfig{ + Enabled: false, + AllowRegistrations: true, + }, + GithubAuth: AuthProviderConfig{ + Enabled: false, + AllowRegistrations: true, + }, + GitlabAuth: AuthProviderConfig{ + Enabled: false, + AllowRegistrations: true, + }, + } +} + +// Validate makes Settings validatable by implementing [validation.Validatable] interface. +func (s *Settings) Validate() error { + s.mux.Lock() + defer s.mux.Unlock() + + return validation.ValidateStruct(s, + validation.Field(&s.Meta), + validation.Field(&s.Logs), + validation.Field(&s.AdminAuthToken), + validation.Field(&s.AdminPasswordResetToken), + validation.Field(&s.UserAuthToken), + validation.Field(&s.UserPasswordResetToken), + validation.Field(&s.UserEmailChangeToken), + validation.Field(&s.UserVerificationToken), + validation.Field(&s.Smtp), + validation.Field(&s.S3), + validation.Field(&s.EmailAuth), + validation.Field(&s.GoogleAuth), + validation.Field(&s.FacebookAuth), + validation.Field(&s.GithubAuth), + validation.Field(&s.GitlabAuth), + ) +} + +// Merge merges `other` settings into the current one. +func (s *Settings) Merge(other *Settings) error { + s.mux.Lock() + defer s.mux.Unlock() + + bytes, err := json.Marshal(other) + if err != nil { + return err + } + + return json.Unmarshal(bytes, s) +} + +// 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 { + return nil, err + } + return new, nil +} + +// RedactClone creates a new deep copy of the current settings, +// while replacing the secret values with `******`. +func (s *Settings) RedactClone() (*Settings, error) { + clone, err := s.Clone() + if err != nil { + return nil, err + } + + mask := "******" + + sensitiveFields := []*string{ + &clone.Smtp.Password, + &clone.S3.Secret, + &clone.AdminAuthToken.Secret, + &clone.AdminPasswordResetToken.Secret, + &clone.UserAuthToken.Secret, + &clone.UserPasswordResetToken.Secret, + &clone.UserEmailChangeToken.Secret, + &clone.UserVerificationToken.Secret, + &clone.GoogleAuth.ClientSecret, + &clone.FacebookAuth.ClientSecret, + &clone.GithubAuth.ClientSecret, + &clone.GitlabAuth.ClientSecret, + } + + // mask all sensitive fields + for _, v := range sensitiveFields { + if v != nil && *v != "" { + *v = mask + } + } + + return clone, nil +} + +// NamedAuthProviderConfigs returns a map with all registered OAuth2 +// provider configurations (indexed by their name identifier). +func (s *Settings) NamedAuthProviderConfigs() map[string]AuthProviderConfig { + return map[string]AuthProviderConfig{ + auth.NameGoogle: s.GoogleAuth, + auth.NameFacebook: s.FacebookAuth, + auth.NameGithub: s.GithubAuth, + auth.NameGitlab: s.GitlabAuth, + } +} + +// ------------------------------------------------------------------- + +type TokenConfig struct { + Secret string `form:"secret" json:"secret"` + Duration int64 `form:"duration" json:"duration"` +} + +// Validate makes TokenConfig validatable by implementing [validation.Validatable] interface. +func (c TokenConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.Secret, validation.Required, validation.Length(30, 300)), + validation.Field(&c.Duration, validation.Required, validation.Min(5), validation.Max(31536000)), + ) +} + +// ------------------------------------------------------------------- + +type SmtpConfig struct { + Enabled bool `form:"enabled" json:"enabled"` + Host string `form:"host" json:"host"` + Port int `form:"port" json:"port"` + Username string `form:"username" json:"username"` + Password string `form:"password" json:"password"` + + // Whether to enforce TLS encryption for the mail server connection. + // + // When set to false StartTLS command is send, leaving the server + // to decide whether to upgrade the connection or not. + Tls bool `form:"tls" json:"tls"` +} + +// Validate makes SmtpConfig validatable by implementing [validation.Validatable] interface. +func (c SmtpConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.Host, is.Host, validation.When(c.Enabled, validation.Required)), + validation.Field(&c.Port, validation.When(c.Enabled, validation.Required), validation.Min(0)), + ) +} + +// ------------------------------------------------------------------- + +type S3Config struct { + Enabled bool `form:"enabled" json:"enabled"` + Bucket string `form:"bucket" json:"bucket"` + Region string `form:"region" json:"region"` + Endpoint string `form:"endpoint" json:"endpoint"` + AccessKey string `form:"accessKey" json:"accessKey"` + Secret string `form:"secret" json:"secret"` +} + +// 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.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)), + validation.Field(&c.Secret, validation.When(c.Enabled, validation.Required)), + ) +} + +// ------------------------------------------------------------------- + +type MetaConfig struct { + AppName string `form:"appName" json:"appName"` + AppUrl string `form:"appUrl" json:"appUrl"` + SenderName string `form:"senderName" json:"senderName"` + SenderAddress string `form:"senderAddress" json:"senderAddress"` + UserVerificationUrl string `form:"userVerificationUrl" json:"userVerificationUrl"` + UserResetPasswordUrl string `form:"userResetPasswordUrl" json:"userResetPasswordUrl"` + UserConfirmEmailChangeUrl string `form:"userConfirmEmailChangeUrl" json:"userConfirmEmailChangeUrl"` +} + +// Validate makes MetaConfig validatable by implementing [validation.Validatable] interface. +func (c MetaConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.AppName, validation.Required, validation.Length(1, 255)), + validation.Field(&c.AppUrl, validation.Required, is.URL), + validation.Field(&c.SenderName, validation.Required, validation.Length(1, 255)), + validation.Field(&c.SenderAddress, is.Email, validation.Required), + validation.Field( + &c.UserVerificationUrl, + validation.Required, + validation.By(c.checkPlaceholders(EmailPlaceholderToken)), + ), + validation.Field( + &c.UserResetPasswordUrl, + validation.Required, + validation.By(c.checkPlaceholders(EmailPlaceholderToken)), + ), + validation.Field( + &c.UserConfirmEmailChangeUrl, + validation.Required, + validation.By(c.checkPlaceholders(EmailPlaceholderToken)), + ), + ) +} + +func (c *MetaConfig) checkPlaceholders(params ...string) validation.RuleFunc { + return func(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + for _, param := range params { + if !strings.Contains(v, param) { + return validation.NewError("validation_missing_required_param", fmt.Sprintf("Missing required parameter %q", param)) + } + } + + return nil + } +} + +// ------------------------------------------------------------------- + +type LogsConfig struct { + MaxDays int `form:"maxDays" json:"maxDays"` +} + +// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface. +func (c LogsConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.MaxDays, validation.Min(0)), + ) +} + +// ------------------------------------------------------------------- + +type AuthProviderConfig struct { + Enabled bool `form:"enabled" json:"enabled"` + AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"` + ClientId string `form:"clientId" json:"clientId,omitempty"` + ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"` + AuthUrl string `form:"authUrl" json:"authUrl,omitempty"` + TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"` + UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"` +} + +// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface. +func (c AuthProviderConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.ClientId, validation.When(c.Enabled, validation.Required)), + validation.Field(&c.ClientSecret, validation.When(c.Enabled, validation.Required)), + validation.Field(&c.AuthUrl, is.URL), + validation.Field(&c.TokenUrl, is.URL), + validation.Field(&c.UserApiUrl, is.URL), + ) +} + +// SetupProvider loads the current AuthProviderConfig into the specified provider. +func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error { + if !c.Enabled { + return errors.New("The provider is not enabled.") + } + + if c.ClientId != "" { + provider.SetClientId(string(c.ClientId)) + } + + if c.ClientSecret != "" { + provider.SetClientSecret(string(c.ClientSecret)) + } + + if c.AuthUrl != "" { + provider.SetAuthUrl(c.AuthUrl) + } + + if c.UserApiUrl != "" { + provider.SetUserApiUrl(c.UserApiUrl) + } + + if c.TokenUrl != "" { + provider.SetTokenUrl(c.TokenUrl) + } + + return nil +} + +// ------------------------------------------------------------------- + +type EmailAuthConfig struct { + Enabled bool `form:"enabled" json:"enabled"` + ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"` + OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"` + MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"` +} + +// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface. +func (c EmailAuthConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field( + &c.ExceptDomains, + validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), + ), + validation.Field( + &c.OnlyDomains, + validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)), + ), + validation.Field( + &c.MinPasswordLength, + validation.When(c.Enabled, validation.Required), + validation.Min(5), + validation.Max(100), + ), + ) +} diff --git a/core/settings_test.go b/core/settings_test.go new file mode 100644 index 000000000..a5c25a496 --- /dev/null +++ b/core/settings_test.go @@ -0,0 +1,606 @@ +package core_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/auth" +) + +func TestSettingsValidate(t *testing.T) { + s := core.NewSettings() + + // set invalid settings data + s.Meta.AppName = "" + s.Logs.MaxDays = -10 + s.Smtp.Enabled = true + s.Smtp.Host = "" + s.S3.Enabled = true + s.S3.Endpoint = "invalid" + s.AdminAuthToken.Duration = -10 + s.AdminPasswordResetToken.Duration = -10 + s.UserAuthToken.Duration = -10 + s.UserPasswordResetToken.Duration = -10 + s.UserEmailChangeToken.Duration = -10 + s.UserVerificationToken.Duration = -10 + s.EmailAuth.Enabled = true + s.EmailAuth.MinPasswordLength = -10 + s.GoogleAuth.Enabled = true + s.GoogleAuth.ClientId = "" + s.FacebookAuth.Enabled = true + s.FacebookAuth.ClientId = "" + s.GithubAuth.Enabled = true + s.GithubAuth.ClientId = "" + s.GitlabAuth.Enabled = true + s.GitlabAuth.ClientId = "" + + // check if Validate() is triggering the members validate methods. + err := s.Validate() + if err == nil { + t.Fatalf("Expected error, got nil") + } + + expectations := []string{ + `"meta":{`, + `"logs":{`, + `"smtp":{`, + `"s3":{`, + `"adminAuthToken":{`, + `"adminPasswordResetToken":{`, + `"userAuthToken":{`, + `"userPasswordResetToken":{`, + `"userEmailChangeToken":{`, + `"userVerificationToken":{`, + `"emailAuth":{`, + `"googleAuth":{`, + `"facebookAuth":{`, + `"githubAuth":{`, + `"gitlabAuth":{`, + } + + errBytes, _ := json.Marshal(err) + jsonErr := string(errBytes) + for _, expected := range expectations { + if !strings.Contains(jsonErr, expected) { + t.Errorf("Expected error key %s in %v", expected, jsonErr) + } + } +} + +func TestSettingsMerge(t *testing.T) { + s1 := core.NewSettings() + s1.Meta.AppUrl = "old_app_url" + + s2 := core.NewSettings() + s2.Meta.AppName = "test" + s2.Logs.MaxDays = 123 + s2.Smtp.Host = "test" + s2.Smtp.Enabled = true + s2.S3.Enabled = true + s2.S3.Endpoint = "test" + s2.AdminAuthToken.Duration = 1 + s2.AdminPasswordResetToken.Duration = 2 + s2.UserAuthToken.Duration = 3 + s2.UserPasswordResetToken.Duration = 4 + s2.UserEmailChangeToken.Duration = 5 + s2.UserVerificationToken.Duration = 6 + s2.EmailAuth.Enabled = false + s2.EmailAuth.MinPasswordLength = 30 + s2.GoogleAuth.Enabled = true + s2.GoogleAuth.ClientId = "google_test" + s2.FacebookAuth.Enabled = true + s2.FacebookAuth.ClientId = "facebook_test" + s2.GithubAuth.Enabled = true + s2.GithubAuth.ClientId = "github_test" + s2.GitlabAuth.Enabled = true + s2.GitlabAuth.ClientId = "gitlab_test" + + if err := s1.Merge(s2); err != nil { + t.Fatal(err) + } + + s1Encoded, err := json.Marshal(s1) + if err != nil { + t.Fatal(err) + } + + s2Encoded, err := json.Marshal(s2) + if err != nil { + t.Fatal(err) + } + + if string(s1Encoded) != string(s2Encoded) { + t.Fatalf("Expected the same serialization, got %v VS %v", string(s1Encoded), string(s2Encoded)) + } +} + +func TestSettingsClone(t *testing.T) { + s1 := core.NewSettings() + + s2, err := s1.Clone() + if err != nil { + t.Fatal(err) + } + + s1Bytes, err := json.Marshal(s1) + if err != nil { + t.Fatal(err) + } + + s2Bytes, err := json.Marshal(s2) + if err != nil { + t.Fatal(err) + } + + if string(s1Bytes) != string(s2Bytes) { + t.Fatalf("Expected equivalent serialization, got %v VS %v", string(s1Bytes), string(s2Bytes)) + } + + // verify that it is a deep copy + s1.Meta.AppName = "new" + if s1.Meta.AppName == s2.Meta.AppName { + t.Fatalf("Expected s1 and s2 to have different Meta.AppName, got %s", s1.Meta.AppName) + } +} + +func TestSettingsRedactClone(t *testing.T) { + s1 := core.NewSettings() + s1.Meta.AppName = "test123" // control field + s1.Smtp.Password = "test123" + s1.Smtp.Tls = true + s1.S3.Secret = "test123" + s1.AdminAuthToken.Secret = "test123" + s1.AdminPasswordResetToken.Secret = "test123" + s1.UserAuthToken.Secret = "test123" + s1.UserPasswordResetToken.Secret = "test123" + s1.UserEmailChangeToken.Secret = "test123" + s1.UserVerificationToken.Secret = "test123" + s1.GoogleAuth.ClientSecret = "test123" + s1.FacebookAuth.ClientSecret = "test123" + s1.GithubAuth.ClientSecret = "test123" + s1.GitlabAuth.ClientSecret = "test123" + + s2, err := s1.RedactClone() + if err != nil { + t.Fatal(err) + } + + encoded, err := json.Marshal(s2) + if err != nil { + t.Fatal(err) + } + + expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","senderName":"Support","senderAddress":"support@example.com","userVerificationUrl":"%APP_URL%/_/#/users/confirm-verification/%TOKEN%","userResetPasswordUrl":"%APP_URL%/_/#/users/confirm-password-reset/%TOKEN%","userConfirmEmailChangeUrl":"%APP_URL%/_/#/users/confirm-email-change/%TOKEN%"},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******"},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}` + + if encodedStr := string(encoded); encodedStr != expected { + t.Fatalf("Expected %v, got \n%v", expected, encodedStr) + } +} + +func TestNamedAuthProviderConfigs(t *testing.T) { + s := core.NewSettings() + + s.GoogleAuth.ClientId = "google_test" + s.FacebookAuth.ClientId = "facebook_test" + s.GithubAuth.ClientId = "github_test" + s.GitlabAuth.ClientId = "gitlab_test" + s.GitlabAuth.Enabled = true + + result := s.NamedAuthProviderConfigs() + + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + expected := `{"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"}}` + + if encodedStr := string(encoded); encodedStr != expected { + t.Fatalf("Expected the same serialization, got %v", encodedStr) + } +} + +func TestTokenConfigValidate(t *testing.T) { + scenarios := []struct { + config core.TokenConfig + expectError bool + }{ + // zero values + { + core.TokenConfig{}, + true, + }, + // invalid data + { + core.TokenConfig{ + Secret: "test", + Duration: 4, + }, + true, + }, + // valid data + { + core.TokenConfig{ + Secret: "testtesttesttesttesttesttestte", + Duration: 100, + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestSmtpConfigValidate(t *testing.T) { + scenarios := []struct { + config core.SmtpConfig + expectError bool + }{ + // zero values (disabled) + { + core.SmtpConfig{}, + false, + }, + // zero values (enabled) + { + core.SmtpConfig{Enabled: true}, + true, + }, + // invalid data + { + core.SmtpConfig{ + Enabled: true, + Host: "test:test:test", + Port: -10, + }, + true, + }, + // valid data + { + core.SmtpConfig{ + Enabled: true, + Host: "example.com", + Port: 100, + Tls: true, + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestS3ConfigValidate(t *testing.T) { + scenarios := []struct { + config core.S3Config + expectError bool + }{ + // zero values (disabled) + { + core.S3Config{}, + false, + }, + // zero values (enabled) + { + core.S3Config{Enabled: true}, + true, + }, + // invalid data + { + core.S3Config{ + Enabled: true, + Endpoint: "test:test:test", + }, + true, + }, + // valid data + { + core.S3Config{ + Enabled: true, + Endpoint: "example.com", + Bucket: "test", + Region: "test", + AccessKey: "test", + Secret: "test", + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestMetaConfigValidate(t *testing.T) { + scenarios := []struct { + config core.MetaConfig + expectError bool + }{ + // zero values + { + core.MetaConfig{}, + true, + }, + // invalid data + { + core.MetaConfig{ + AppName: strings.Repeat("a", 300), + AppUrl: "test", + SenderName: strings.Repeat("a", 300), + SenderAddress: "invalid_email", + UserVerificationUrl: "test", + UserResetPasswordUrl: "test", + UserConfirmEmailChangeUrl: "test", + }, + true, + }, + // invalid data (missing required placeholders) + { + core.MetaConfig{ + AppName: "test", + AppUrl: "https://example.com", + SenderName: "test", + SenderAddress: "test@example.com", + UserVerificationUrl: "https://example.com", + UserResetPasswordUrl: "https://example.com", + UserConfirmEmailChangeUrl: "https://example.com", + }, + true, + }, + // valid data + { + core.MetaConfig{ + AppName: "test", + AppUrl: "https://example.com", + SenderName: "test", + SenderAddress: "test@example.com", + UserVerificationUrl: "https://example.com/" + core.EmailPlaceholderToken, + UserResetPasswordUrl: "https://example.com/" + core.EmailPlaceholderToken, + UserConfirmEmailChangeUrl: "https://example.com/" + core.EmailPlaceholderToken, + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestLogsConfigValidate(t *testing.T) { + scenarios := []struct { + config core.LogsConfig + expectError bool + }{ + // zero values + { + core.LogsConfig{}, + false, + }, + // invalid data + { + core.LogsConfig{MaxDays: -10}, + true, + }, + // valid data + { + core.LogsConfig{MaxDays: 1}, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestAuthProviderConfigValidate(t *testing.T) { + scenarios := []struct { + config core.AuthProviderConfig + expectError bool + }{ + // zero values (disabled) + { + core.AuthProviderConfig{}, + false, + }, + // zero values (enabled) + { + core.AuthProviderConfig{Enabled: true}, + true, + }, + // invalid data + { + core.AuthProviderConfig{ + Enabled: true, + ClientId: "", + ClientSecret: "", + AuthUrl: "test", + TokenUrl: "test", + UserApiUrl: "test", + }, + true, + }, + // valid data (only the required) + { + core.AuthProviderConfig{ + Enabled: true, + ClientId: "test", + ClientSecret: "test", + }, + false, + }, + // valid data (fill all fields) + { + core.AuthProviderConfig{ + Enabled: true, + ClientId: "test", + ClientSecret: "test", + AuthUrl: "https://example.com", + TokenUrl: "https://example.com", + UserApiUrl: "https://example.com", + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} + +func TestAuthProviderConfigSetupProvider(t *testing.T) { + provider := auth.NewGithubProvider() + + // disabled config + c1 := core.AuthProviderConfig{Enabled: false} + if err := c1.SetupProvider(provider); err == nil { + t.Errorf("Expected error, got nil") + } + + c2 := core.AuthProviderConfig{ + Enabled: true, + ClientId: "test_ClientId", + ClientSecret: "test_ClientSecret", + AuthUrl: "test_AuthUrl", + UserApiUrl: "test_UserApiUrl", + TokenUrl: "test_TokenUrl", + } + if err := c2.SetupProvider(provider); err != nil { + t.Error(err) + } + encoded, _ := json.Marshal(c2) + expected := `{"enabled":true,"allowRegistrations":false,"clientId":"test_ClientId","clientSecret":"test_ClientSecret","authUrl":"test_AuthUrl","tokenUrl":"test_TokenUrl","userApiUrl":"test_UserApiUrl"}` + if string(encoded) != expected { + t.Errorf("Expected %s, got %s", expected, string(encoded)) + } +} + +func TestEmailAuthConfigValidate(t *testing.T) { + scenarios := []struct { + config core.EmailAuthConfig + expectError bool + }{ + // zero values (disabled) + { + core.EmailAuthConfig{}, + false, + }, + // zero values (enabled) + { + core.EmailAuthConfig{Enabled: true}, + true, + }, + // invalid data (only the required) + { + core.EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 4, + }, + true, + }, + // valid data (only the required) + { + core.EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 5, + }, + false, + }, + // invalid data (both OnlyDomains and ExceptDomains set) + { + core.EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 5, + OnlyDomains: []string{"example.com", "test.com"}, + ExceptDomains: []string{"example.com", "test.com"}, + }, + true, + }, + // valid data (only onlyDomains set) + { + core.EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 5, + OnlyDomains: []string{"example.com", "test.com"}, + }, + false, + }, + // valid data (only exceptDomains set) + { + core.EmailAuthConfig{ + Enabled: true, + MinPasswordLength: 5, + ExceptDomains: []string{"example.com", "test.com"}, + }, + false, + }, + } + + for i, scenario := range scenarios { + result := scenario.config.Validate() + + if result != nil && !scenario.expectError { + t.Errorf("(%d) Didn't expect error, got %v", i, result) + } + + if result == nil && scenario.expectError { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} diff --git a/daos/admin.go b/daos/admin.go new file mode 100644 index 000000000..13b196dfe --- /dev/null +++ b/daos/admin.go @@ -0,0 +1,124 @@ +package daos + +import ( + "errors" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/security" +) + +// AdminQuery returns a new Admin select query. +func (dao *Dao) AdminQuery() *dbx.SelectQuery { + return dao.ModelQuery(&models.Admin{}) +} + +// FindAdminById finds the admin with the provided id. +func (dao *Dao) FindAdminById(id string) (*models.Admin, error) { + model := &models.Admin{} + + err := dao.AdminQuery(). + AndWhere(dbx.HashExp{"id": id}). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + return model, nil +} + +// FindAdminByEmail finds the admin with the provided email address. +func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) { + model := &models.Admin{} + + err := dao.AdminQuery(). + AndWhere(dbx.HashExp{"email": email}). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + return model, nil +} + +// FindAdminByEmail 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) { + unverifiedClaims, err := security.ParseUnverifiedJWT(token) + if err != nil { + return nil, err + } + + // check required claims + id, _ := unverifiedClaims["id"].(string) + if id == "" { + return nil, errors.New("Missing or invalid token claims.") + } + + admin, err := dao.FindAdminById(id) + if err != nil || admin == nil { + return nil, err + } + + verificationKey := admin.TokenKey + baseTokenKey + + // verify token signature + if _, err := security.ParseJWT(token, verificationKey); err != nil { + return nil, err + } + + return admin, nil +} + +// TotalAdmins returns the number of existing admin records. +func (dao *Dao) TotalAdmins() (int, error) { + var total int + + err := dao.AdminQuery().Select("count(*)").Row(&total) + + return total, err +} + +// IsAdminEmailUnique checks if the provided email address is not +// already in use by other admins. +func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool { + if email == "" { + return false + } + + var exists bool + err := dao.AdminQuery(). + Select("count(*)"). + AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})). + AndWhere(dbx.HashExp{"email": email}). + Limit(1). + Row(&exists) + + return err == nil && !exists +} + +// DeleteAdmin deletes the provided Admin model. +// +// Returns an error if there is only 1 admin. +func (dao *Dao) DeleteAdmin(admin *models.Admin) error { + total, err := dao.TotalAdmins() + if err != nil { + return err + } + + if total == 1 { + return errors.New("You cannot delete the only existing admin.") + } + + return dao.Delete(admin) +} + +// SaveAdmin upserts the provided Admin model. +func (dao *Dao) SaveAdmin(admin *models.Admin) error { + return dao.Save(admin) +} diff --git a/daos/admin_test.go b/daos/admin_test.go new file mode 100644 index 000000000..4f56b7e5c --- /dev/null +++ b/daos/admin_test.go @@ -0,0 +1,238 @@ +package daos_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestAdminQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + expected := "SELECT {{_admins}}.* FROM `_admins`" + + sql := app.Dao().AdminQuery().Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestFindAdminById(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + id string + expectError bool + }{ + {"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true}, + {"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false}, + } + + for i, scenario := range scenarios { + admin, err := app.Dao().FindAdminById(scenario.id) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if admin != nil && admin.Id != scenario.id { + t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id) + } + } +} + +func TestFindAdminByEmail(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + email string + expectError bool + }{ + {"invalid", true}, + {"missing@example.com", true}, + {"test@example.com", false}, + } + + for i, scenario := range scenarios { + admin, err := app.Dao().FindAdminByEmail(scenario.email) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + continue + } + + if !scenario.expectError && admin.Email != scenario.email { + t.Errorf("(%d) Expected admin with email %s, got %s", i, scenario.email, admin.Email) + } + } +} + +func TestFindAdminByToken(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + token string + baseKey string + expectedEmail string + expectError bool + }{ + // invalid base key (password reset key for auth token) + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + app.Settings().AdminPasswordResetToken.Secret, + "", + true, + }, + // expired token + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8", + app.Settings().AdminAuthToken.Secret, + "", + true, + }, + // valid token + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + app.Settings().AdminAuthToken.Secret, + "test@example.com", + false, + }, + } + + for i, scenario := range scenarios { + admin, err := app.Dao().FindAdminByToken(scenario.token, scenario.baseKey) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + continue + } + + if !scenario.expectError && admin.Email != scenario.expectedEmail { + t.Errorf("(%d) Expected admin model %s, got %s", i, scenario.expectedEmail, admin.Email) + } + } +} + +func TestTotalAdmins(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + result1, err := app.Dao().TotalAdmins() + if err != nil { + t.Fatal(err) + } + if result1 != 2 { + t.Fatalf("Expected 2 admins, got %d", result1) + } + + // delete all + app.Dao().DB().NewQuery("delete from {{_admins}}").Execute() + + result2, err := app.Dao().TotalAdmins() + if err != nil { + t.Fatal(err) + } + if result2 != 0 { + t.Fatalf("Expected 0 admins, got %d", result2) + } +} + +func TestIsAdminEmailUnique(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + email string + excludeId string + expected bool + }{ + {"", "", false}, + {"test@example.com", "", false}, + {"new@example.com", "", true}, + {"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true}, + } + + for i, scenario := range scenarios { + result := app.Dao().IsAdminEmailUnique(scenario.email, scenario.excludeId) + if result != scenario.expected { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) + } + } +} + +func TestDeleteAdmin(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // try to delete unsaved admin model + deleteErr0 := app.Dao().DeleteAdmin(&models.Admin{}) + if deleteErr0 == nil { + t.Fatal("Expected error, got nil") + } + + admin1, err := app.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + admin2, err := app.Dao().FindAdminByEmail("test2@example.com") + if err != nil { + t.Fatal(err) + } + + deleteErr1 := app.Dao().DeleteAdmin(admin1) + if deleteErr1 != nil { + t.Fatal(deleteErr1) + } + + // cannot delete the only remaining admin + deleteErr2 := app.Dao().DeleteAdmin(admin2) + if deleteErr2 == nil { + t.Fatal("Expected delete error, got nil") + } + + total, _ := app.Dao().TotalAdmins() + if total != 1 { + t.Fatalf("Expected only 1 admin, got %d", total) + } +} + +func TestSaveAdmin(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create + newAdmin := &models.Admin{} + newAdmin.Email = "new@example.com" + newAdmin.SetPassword("123456") + saveErr1 := app.Dao().SaveAdmin(newAdmin) + if saveErr1 != nil { + t.Fatal(saveErr1) + } + if newAdmin.Id == "" { + t.Fatal("Expected admin id to be set") + } + + // update + existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + updatedEmail := "test_update@example.com" + existingAdmin.Email = updatedEmail + saveErr2 := app.Dao().SaveAdmin(existingAdmin) + if saveErr2 != nil { + t.Fatal(saveErr2) + } + existingAdmin, _ = app.Dao().FindAdminById(existingAdmin.Id) + if existingAdmin.Email != updatedEmail { + t.Fatalf("Expected admin email to be %s, got %s", updatedEmail, existingAdmin.Email) + } +} diff --git a/daos/base.go b/daos/base.go new file mode 100644 index 000000000..5c0a2bf56 --- /dev/null +++ b/daos/base.go @@ -0,0 +1,217 @@ +// Package daos handles common PocketBase DB model manipulations. +// +// Think of daos as DB repository and service layer in one. +package daos + +import ( + "errors" + "fmt" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" +) + +// New creates a new Dao instance with the provided db builder. +func New(db dbx.Builder) *Dao { + return &Dao{ + db: db, + } +} + +// Dao handles various db operations. +// Think of Dao as a repository and service layer in one. +type Dao struct { + db dbx.Builder + + BeforeCreateFunc func(eventDao *Dao, m models.Model) error + AfterCreateFunc func(eventDao *Dao, m models.Model) + BeforeUpdateFunc func(eventDao *Dao, m models.Model) error + AfterUpdateFunc func(eventDao *Dao, m models.Model) + BeforeDeleteFunc func(eventDao *Dao, m models.Model) error + AfterDeleteFunc func(eventDao *Dao, m models.Model) +} + +// DB returns the internal db builder (*dbx.DB or *dbx.TX). +func (dao *Dao) DB() dbx.Builder { + return dao.db +} + +// ModelQuery creates a new query with preset Select and From fields +// based on the provided model argument. +func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery { + tableName := m.TableName() + return dao.db.Select(fmt.Sprintf("{{%s}}.*", tableName)).From(tableName) +} + +// FindById finds a single db record with the specified id and +// scans the result into m. +func (dao *Dao) FindById(m models.Model, id string) error { + return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m) +} + +// RunInTransaction wraps fn into a transaction. +// +// It is safe to nest RunInTransaction calls. +func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error { + switch txOrDB := dao.db.(type) { + case *dbx.Tx: + // nested transactions are not supported by default + // so execute the function within the current transaction + return fn(dao) + case *dbx.DB: + return txOrDB.Transactional(func(tx *dbx.Tx) error { + txDao := New(tx) + + txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error { + if dao.BeforeCreateFunc != nil { + return dao.BeforeCreateFunc(eventDao, m) + } + return nil + } + txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) { + if dao.AfterCreateFunc != nil { + dao.AfterCreateFunc(eventDao, m) + } + } + txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error { + if dao.BeforeUpdateFunc != nil { + return dao.BeforeUpdateFunc(eventDao, m) + } + return nil + } + txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) { + if dao.AfterUpdateFunc != nil { + dao.AfterUpdateFunc(eventDao, m) + } + } + txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error { + if dao.BeforeDeleteFunc != nil { + return dao.BeforeDeleteFunc(eventDao, m) + } + return nil + } + txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) { + if dao.AfterDeleteFunc != nil { + dao.AfterDeleteFunc(eventDao, m) + } + } + + return fn(txDao) + }) + } + + return errors.New("Failed to start transaction (unknown dao.db)") +} + +// Delete deletes the provided model. +func (dao *Dao) Delete(m models.Model) error { + if !m.HasId() { + return errors.New("ID is not set") + } + + if dao.BeforeDeleteFunc != nil { + if err := dao.BeforeDeleteFunc(dao, m); err != nil { + return err + } + } + + deleteErr := dao.db.Model(m).Delete() + if deleteErr != nil { + return deleteErr + } + + if dao.AfterDeleteFunc != nil { + dao.AfterDeleteFunc(dao, m) + } + + return nil +} + +// Save upserts (update or create if primary key is not set) the provided model. +func (dao *Dao) Save(m models.Model) error { + if m.HasId() { + return dao.update(m) + } + + return dao.create(m) +} + +func (dao *Dao) update(m models.Model) error { + if !m.HasId() { + return errors.New("ID is not set") + } + + m.RefreshUpdated() + + if dao.BeforeUpdateFunc != nil { + if err := dao.BeforeUpdateFunc(dao, m); err != nil { + return err + } + } + + if v, ok := any(m).(models.ColumnValueMapper); ok { + dataMap := v.ColumnValueMap() + + _, err := dao.db.Update( + m.TableName(), + dataMap, + dbx.HashExp{"id": m.GetId()}, + ).Execute() + + if err != nil { + return err + } + } else { + err := dao.db.Model(m).Update() + if err != nil { + return err + } + } + + if dao.AfterUpdateFunc != nil { + dao.AfterUpdateFunc(dao, m) + } + + return nil +} + +func (dao *Dao) create(m models.Model) error { + if !m.HasId() { + // auto generate id + m.RefreshId() + } + + if m.GetCreated().IsZero() { + m.RefreshCreated() + } + + if m.GetUpdated().IsZero() { + m.RefreshUpdated() + } + + if dao.BeforeCreateFunc != nil { + if err := dao.BeforeCreateFunc(dao, m); err != nil { + return err + } + } + + if v, ok := any(m).(models.ColumnValueMapper); ok { + dataMap := v.ColumnValueMap() + + _, err := dao.db.Insert(m.TableName(), dataMap).Execute() + if err != nil { + return err + } + } else { + err := dao.db.Model(m).Insert() + if err != nil { + return err + } + } + + if dao.AfterCreateFunc != nil { + dao.AfterCreateFunc(dao, m) + } + + return nil +} diff --git a/daos/base_test.go b/daos/base_test.go new file mode 100644 index 000000000..31d12fc95 --- /dev/null +++ b/daos/base_test.go @@ -0,0 +1,245 @@ +package daos_test + +import ( + "errors" + "testing" + + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestNew(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + dao := daos.New(testApp.DB()) + + if dao.DB() != testApp.DB() { + t.Fatal("The 2 db instances are different") + } +} + +func TestDaoModelQuery(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + dao := daos.New(testApp.DB()) + + scenarios := []struct { + model models.Model + expected string + }{ + { + &models.Collection{}, + "SELECT {{_collections}}.* FROM `_collections`", + }, + { + &models.User{}, + "SELECT {{_users}}.* FROM `_users`", + }, + { + &models.Request{}, + "SELECT {{_requests}}.* FROM `_requests`", + }, + } + + for i, scenario := range scenarios { + sql := dao.ModelQuery(scenario.model).Build().SQL() + if sql != scenario.expected { + t.Errorf("(%d) Expected select %s, got %s", i, scenario.expected, sql) + } + } +} + +func TestDaoFindById(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + scenarios := []struct { + model models.Model + id string + expectError bool + }{ + // missing id + { + &models.Collection{}, + "00000000-075d-49fe-9d09-ea7e951000dc", + true, + }, + // existing collection id + { + &models.Collection{}, + "3f2888f8-075d-49fe-9d09-ea7e951000dc", + false, + }, + // existing user id + { + &models.User{}, + "97cc3d3d-6ba2-383f-b42a-7bc84d27410c", + false, + }, + } + + for i, scenario := range scenarios { + err := testApp.Dao().FindById(scenario.model, scenario.id) + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expectError, err) + } + + if !scenario.expectError && scenario.id != scenario.model.GetId() { + t.Errorf("(%d) Expected model with id %v, got %v", i, scenario.id, scenario.model.GetId()) + } + } +} + +func TestDaoRunInTransaction(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + // failed nested transaction + testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error { + admin, _ := txDao.FindAdminByEmail("test@example.com") + + return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error { + if err := tx2Dao.DeleteAdmin(admin); err != nil { + t.Fatal(err) + } + return errors.New("test error") + }) + }) + + // admin should still exist + admin1, _ := testApp.Dao().FindAdminByEmail("test@example.com") + if admin1 == nil { + t.Fatal("Expected admin test@example.com to not be deleted") + } + + // successful nested transaction + testApp.Dao().RunInTransaction(func(txDao *daos.Dao) error { + admin, _ := txDao.FindAdminByEmail("test@example.com") + + return txDao.RunInTransaction(func(tx2Dao *daos.Dao) error { + return tx2Dao.DeleteAdmin(admin) + }) + }) + + // admin should have been deleted + admin2, _ := testApp.Dao().FindAdminByEmail("test@example.com") + if admin2 != nil { + t.Fatalf("Expected admin test@example.com to be deleted, found %v", admin2) + } +} + +func TestDaoSaveCreate(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + model := &models.Admin{} + model.Email = "test_new@example.com" + model.Avatar = 8 + if err := testApp.Dao().Save(model); err != nil { + t.Fatal(err) + } + + // refresh + model, _ = testApp.Dao().FindAdminByEmail("test_new@example.com") + + if model.Avatar != 8 { + t.Fatalf("Expected model avatar field to be 8, got %v", model.Avatar) + } + + expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"} + for _, h := range expectedHooks { + if v, ok := testApp.EventCalls[h]; !ok || v != 1 { + t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) + } + } +} + +func TestDaoSaveUpdate(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + model, _ := testApp.Dao().FindAdminByEmail("test@example.com") + + model.Avatar = 8 + if err := testApp.Dao().Save(model); err != nil { + t.Fatal(err) + } + + // refresh + model, _ = testApp.Dao().FindAdminByEmail("test@example.com") + + if model.Avatar != 8 { + t.Fatalf("Expected model avatar field to be updated to 8, got %v", model.Avatar) + } + + expectedHooks := []string{"OnModelBeforeUpdate", "OnModelAfterUpdate"} + for _, h := range expectedHooks { + if v, ok := testApp.EventCalls[h]; !ok || v != 1 { + t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) + } + } +} + +func TestDaoDelete(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + model, _ := testApp.Dao().FindAdminByEmail("test@example.com") + + if err := testApp.Dao().Delete(model); err != nil { + t.Fatal(err) + } + + model, _ = testApp.Dao().FindAdminByEmail("test@example.com") + if model != nil { + t.Fatalf("Expected model to be deleted, found %v", model) + } + + expectedHooks := []string{"OnModelBeforeDelete", "OnModelAfterDelete"} + for _, h := range expectedHooks { + if v, ok := testApp.EventCalls[h]; !ok || v != 1 { + t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) + } + } +} + +func TestDaoBeforeHooksError(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + testApp.Dao().BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error { + return errors.New("before_create") + } + testApp.Dao().BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error { + return errors.New("before_update") + } + testApp.Dao().BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error { + return errors.New("before_delete") + } + + existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com") + + // try to create + // --- + newModel := &models.Admin{} + newModel.Email = "test_new@example.com" + if err := testApp.Dao().Save(newModel); err.Error() != "before_create" { + t.Fatalf("Expected before_create error, got %v", err) + } + + // try to update + // --- + if err := testApp.Dao().Save(existingModel); err.Error() != "before_update" { + t.Fatalf("Expected before_update error, got %v", err) + } + + // try to delete + // --- + if err := testApp.Dao().Delete(existingModel); err.Error() != "before_delete" { + t.Fatalf("Expected before_delete error, got %v", err) + } +} diff --git a/daos/collection.go b/daos/collection.go new file mode 100644 index 000000000..62100333d --- /dev/null +++ b/daos/collection.go @@ -0,0 +1,163 @@ +package daos + +import ( + "errors" + "fmt" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" +) + +// CollectionQuery returns a new Collection select query. +func (dao *Dao) CollectionQuery() *dbx.SelectQuery { + return dao.ModelQuery(&models.Collection{}) +} + +// FindCollectionByNameOrId finds the first collection by its name or id. +func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) { + model := &models.Collection{} + + err := dao.CollectionQuery(). + AndWhere(dbx.Or( + dbx.HashExp{"id": nameOrId}, + dbx.HashExp{"name": nameOrId}, + )). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + return model, nil +} + +// IsCollectionNameUnique checks that there is no existing collection +// with the provided name (case insensitive!). +// +// Note: case sensitive check because the name is used also as a table name for the records. +func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool { + if name == "" { + return false + } + + var exists bool + err := dao.CollectionQuery(). + Select("count(*)"). + AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})). + AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})). + Limit(1). + Row(&exists) + + return err == nil && !exists +} + +// FindCollectionsWithUserFields finds all collections that has +// at least one user schema field. +func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) { + result := []*models.Collection{} + + err := dao.CollectionQuery(). + InnerJoin( + "json_each(schema) as jsonField", + dbx.NewExp( + "json_extract(jsonField.value, '$.type') = {:type}", + dbx.Params{"type": schema.FieldTypeUser}, + ), + ). + All(&result) + + return result, err +} + +// FindCollectionReferences returns information for all +// relation schema fields referencing the provided collection. +// +// If the provided collection has reference to itself then it will be +// also included in the result. To exlude it, pass the collection id +// as the excludeId argument. +func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) { + collections := []*models.Collection{} + + err := dao.CollectionQuery(). + AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})). + All(&collections) + if err != nil { + return nil, err + } + + result := map[*models.Collection][]*schema.SchemaField{} + for _, c := range collections { + for _, f := range c.Schema.Fields() { + if f.Type != schema.FieldTypeRelation { + continue + } + f.InitOptions() + options, _ := f.Options.(*schema.RelationOptions) + if options != nil && options.CollectionId == collection.Id { + result[c] = append(result[c], f) + } + } + } + + return result, nil +} + +// DeleteCollection deletes the provided Collection model. +// This method automatically deletes the related collection records table. +// +// NB! The collection cannot be deleted, if: +// - is system collection (aka. collection.System is true) +// - is referenced as part of a relation field in another collection +func (dao *Dao) DeleteCollection(collection *models.Collection) error { + if collection.System { + return errors.New("System collections cannot be deleted.") + } + + // ensure that there aren't any existing references. + // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction + result, err := dao.FindCollectionReferences(collection, collection.Id) + if err != nil { + return err + } + if total := len(result); total > 0 { + return fmt.Errorf("The collection has external relation field references (%d).", total) + } + + return dao.RunInTransaction(func(txDao *Dao) error { + // delete the related records table + if err := txDao.DeleteTable(collection.Name); err != nil { + return err + } + + return txDao.Delete(collection) + }) +} + +// SaveCollection upserts the provided Collection model and updates +// its related records table schema. +func (dao *Dao) SaveCollection(collection *models.Collection) error { + var oldCollection *models.Collection + + if collection.HasId() { + // get the existing collection state to compare with the new one + // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction + var findErr error + oldCollection, findErr = dao.FindCollectionByNameOrId(collection.Id) + if findErr != nil { + return findErr + } + } + + return dao.RunInTransaction(func(txDao *Dao) error { + // persist the collection model + if err := txDao.Save(collection); err != nil { + return err + } + + // sync the changes with the related records table + return txDao.SyncRecordTableSchema(collection, oldCollection) + }) +} diff --git a/daos/collection_test.go b/daos/collection_test.go new file mode 100644 index 000000000..7e039e80b --- /dev/null +++ b/daos/collection_test.go @@ -0,0 +1,253 @@ +package daos_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" +) + +func TestCollectionQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + expected := "SELECT {{_collections}}.* FROM `_collections`" + + sql := app.Dao().CollectionQuery().Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestFindCollectionByNameOrId(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + nameOrId string + expectError bool + }{ + {"", true}, + {"missing", true}, + {"00000000-075d-49fe-9d09-ea7e951000dc", true}, + {"3f2888f8-075d-49fe-9d09-ea7e951000dc", false}, + {"demo", false}, + } + + for i, scenario := range scenarios { + model, err := app.Dao().FindCollectionByNameOrId(scenario.nameOrId) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if model != nil && model.Id != scenario.nameOrId && model.Name != scenario.nameOrId { + t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model) + } + } +} + +func TestIsCollectionNameUnique(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + name string + excludeId string + expected bool + }{ + {"", "", false}, + {"demo", "", false}, + {"new", "", true}, + {"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true}, + } + + for i, scenario := range scenarios { + result := app.Dao().IsCollectionNameUnique(scenario.name, scenario.excludeId) + if result != scenario.expected { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) + } + } +} + +func TestFindCollectionsWithUserFields(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + result, err := app.Dao().FindCollectionsWithUserFields() + if err != nil { + t.Fatal(err) + } + + expectedNames := []string{"demo2", models.ProfileCollectionName} + + if len(result) != len(expectedNames) { + t.Fatalf("Expected collections %v, got %v", expectedNames, result) + } + + for i, col := range result { + if !list.ExistInSlice(col.Name, expectedNames) { + t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames) + } + } +} + +func TestFindCollectionReferences(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, err := app.Dao().FindCollectionByNameOrId("demo") + if err != nil { + t.Fatal(err) + } + + result, err := app.Dao().FindCollectionReferences(collection, collection.Id) + if err != nil { + t.Fatal(err) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 collection, got %d: %v", len(result), result) + } + + expectedFields := []string{"onerel", "manyrels", "rel_cascade"} + + for col, fields := range result { + if col.Name != "demo2" { + t.Fatalf("Expected collection demo2, got %s", col.Name) + } + if len(fields) != len(expectedFields) { + t.Fatalf("Expected fields %v, got %v", expectedFields, fields) + } + for i, f := range fields { + if !list.ExistInSlice(f.Name, expectedFields) { + t.Fatalf("(%d) Didn't expect field %v", i, f) + } + } + } +} + +func TestDeleteCollection(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + c0 := &models.Collection{} + c1, err := app.Dao().FindCollectionByNameOrId("demo") + if err != nil { + t.Fatal(err) + } + c2, err := app.Dao().FindCollectionByNameOrId("demo2") + if err != nil { + t.Fatal(err) + } + c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName) + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + model *models.Collection + expectError bool + }{ + {c0, true}, + {c1, true}, // is part of a reference + {c2, false}, + {c3, true}, // system + } + + for i, scenario := range scenarios { + err := app.Dao().DeleteCollection(scenario.model) + hasErr := err != nil + + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) + } + } + +} + +func TestSaveCollectionCreate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection := &models.Collection{ + Name: "new_test", + Schema: schema.NewSchema( + &schema.SchemaField{ + Type: schema.FieldTypeText, + Name: "test", + }, + ), + } + + err := app.Dao().SaveCollection(collection) + if err != nil { + t.Fatal(err) + } + + if collection.Id == "" { + t.Fatal("Expected collection id to be set") + } + + // check if the records table was created + hasTable := app.Dao().HasTable(collection.Name) + if !hasTable { + t.Fatalf("Expected records table %s to be created", collection.Name) + } + + // check if the records table has the schema fields + columns, err := app.Dao().GetTableColumns(collection.Name) + if err != nil { + t.Fatal(err) + } + expectedColumns := []string{"id", "created", "updated", "test"} + if len(columns) != len(expectedColumns) { + t.Fatalf("Expected columns %v, got %v", expectedColumns, columns) + } + for i, c := range columns { + if !list.ExistInSlice(c, expectedColumns) { + t.Fatalf("(%d) Didn't expect record column %s", i, c) + } + } +} + +func TestSaveCollectionUpdate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, err := app.Dao().FindCollectionByNameOrId("demo3") + if err != nil { + t.Fatal(err) + } + + // rename an existing schema field and add a new one + oldField := collection.Schema.GetFieldByName("title") + oldField.Name = "title_update" + collection.Schema.AddField(&schema.SchemaField{ + Type: schema.FieldTypeText, + Name: "test", + }) + + saveErr := app.Dao().SaveCollection(collection) + if saveErr != nil { + t.Fatal(saveErr) + } + + // check if the records table has the schema fields + expectedColumns := []string{"id", "created", "updated", "title_update", "test"} + columns, err := app.Dao().GetTableColumns(collection.Name) + if err != nil { + t.Fatal(err) + } + if len(columns) != len(expectedColumns) { + t.Fatalf("Expected columns %v, got %v", expectedColumns, columns) + } + for i, c := range columns { + if !list.ExistInSlice(c, expectedColumns) { + t.Fatalf("(%d) Didn't expect record column %s", i, c) + } + } +} diff --git a/daos/param.go b/daos/param.go new file mode 100644 index 000000000..c17a9c795 --- /dev/null +++ b/daos/param.go @@ -0,0 +1,75 @@ +package daos + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/types" +) + +// ParamQuery returns a new Param select query. +func (dao *Dao) ParamQuery() *dbx.SelectQuery { + return dao.ModelQuery(&models.Param{}) +} + +// FindParamByKey finds the first Param model with the provided key. +func (dao *Dao) FindParamByKey(key string) (*models.Param, error) { + param := &models.Param{} + + err := dao.ParamQuery(). + AndWhere(dbx.HashExp{"key": key}). + Limit(1). + One(param) + + if err != nil { + return nil, err + } + + return param, nil +} + +// SaveParam creates or updates a Param model by the provided key-value pair. +// The value argument will be encoded as json string. +// +// If `optEncryptionKey` is provided it will encrypt the value before storing it. +func (dao *Dao) SaveParam(key string, value any, optEncryptionKey ...string) error { + param, _ := dao.FindParamByKey(key) + if param == nil { + param = &models.Param{Key: key} + } + + var normalizedValue any + + // encrypt if optEncryptionKey is set + if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" { + encoded, encodingErr := json.Marshal(value) + if encodingErr != nil { + return encodingErr + } + + encryptVal, encryptErr := security.Encrypt(encoded, optEncryptionKey[0]) + if encryptErr != nil { + return encryptErr + } + + normalizedValue = encryptVal + } else { + normalizedValue = value + } + + encodedValue := types.JsonRaw{} + if err := encodedValue.Scan(normalizedValue); err != nil { + return err + } + + param.Value = encodedValue + + return dao.Save(param) +} + +// DeleteParam deletes the provided Param model. +func (dao *Dao) DeleteParam(param *models.Param) error { + return dao.Delete(param) +} diff --git a/daos/param_test.go b/daos/param_test.go new file mode 100644 index 000000000..b36460858 --- /dev/null +++ b/daos/param_test.go @@ -0,0 +1,150 @@ +package daos_test + +import ( + "encoding/json" + "testing" + + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestParamQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + expected := "SELECT {{_params}}.* FROM `_params`" + + sql := app.Dao().ParamQuery().Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestFindParamByKey(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + key string + expectError bool + }{ + {"", true}, + {"missing", true}, + {models.ParamAppSettings, false}, + } + + for i, scenario := range scenarios { + param, err := app.Dao().FindParamByKey(scenario.key) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if param != nil && param.Key != scenario.key { + t.Errorf("(%d) Expected param with identifier %s, got %v", i, scenario.key, param.Key) + } + } +} + +func TestSaveParam(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + key string + value any + }{ + {"", "demo"}, + {"test", nil}, + {"test", ""}, + {"test", 1}, + {"test", 123}, + {models.ParamAppSettings, map[string]any{"test": 123}}, + } + + for i, scenario := range scenarios { + err := app.Dao().SaveParam(scenario.key, scenario.value) + if err != nil { + t.Errorf("(%d) %v", i, err) + } + + jsonRaw := types.JsonRaw{} + jsonRaw.Scan(scenario.value) + encodedScenarioValue, err := jsonRaw.MarshalJSON() + if err != nil { + t.Errorf("(%d) Encoded error %v", i, err) + } + + // check if the param was really saved + param, _ := app.Dao().FindParamByKey(scenario.key) + encodedParamValue, err := param.Value.MarshalJSON() + if err != nil { + t.Errorf("(%d) Encoded error %v", i, err) + } + + if string(encodedParamValue) != string(encodedScenarioValue) { + t.Errorf("(%d) Expected the two values to be equal, got %v vs %v", i, string(encodedParamValue), string(encodedScenarioValue)) + } + } +} + +func TestSaveParamEncrypted(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + encryptionKey := security.RandomString(32) + data := map[string]int{"test": 123} + expected := map[string]int{} + + err := app.Dao().SaveParam("test", data, encryptionKey) + if err != nil { + t.Fatal(err) + } + + // check if the param was really saved + param, _ := app.Dao().FindParamByKey("test") + + // decrypt + decrypted, decryptErr := security.Decrypt(string(param.Value), encryptionKey) + if decryptErr != nil { + t.Fatal(decryptErr) + } + + // decode + decryptedDecodeErr := json.Unmarshal(decrypted, &expected) + if decryptedDecodeErr != nil { + t.Fatal(decryptedDecodeErr) + } + + // check if the decoded value is correct + if len(expected) != len(data) || expected["test"] != data["test"] { + t.Fatalf("Expected %v, got %v", expected, data) + } +} + +func TestDeleteParam(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // unsaved param + err1 := app.Dao().DeleteParam(&models.Param{}) + if err1 == nil { + t.Fatal("Expected error, got nil") + } + + // existing param + param, _ := app.Dao().FindParamByKey(models.ParamAppSettings) + err2 := app.Dao().DeleteParam(param) + if err2 != nil { + t.Fatalf("Expected nil, got error %v", err2) + } + + // check if it was really deleted + paramCheck, _ := app.Dao().FindParamByKey(models.ParamAppSettings) + if paramCheck != nil { + t.Fatalf("Expected param to be deleted, got %v", paramCheck) + } +} diff --git a/daos/record.go b/daos/record.go new file mode 100644 index 000000000..1ad218674 --- /dev/null +++ b/daos/record.go @@ -0,0 +1,351 @@ +package daos + +import ( + "errors" + "fmt" + "strings" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/types" +) + +// RecordQuery returns a new Record select query. +func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery { + tableName := collection.Name + selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName)) + + return dao.DB().Select(selectCols).From(tableName) +} + +// FindRecordById finds the Record model by its id. +func (dao *Dao) FindRecordById( + collection *models.Collection, + recordId string, + filter func(q *dbx.SelectQuery) error, +) (*models.Record, error) { + tableName := collection.Name + + query := dao.RecordQuery(collection). + AndWhere(dbx.HashExp{tableName + ".id": recordId}) + + if filter != nil { + if err := filter(query); err != nil { + return nil, err + } + } + + row := dbx.NullStringMap{} + if err := query.Limit(1).One(row); err != nil { + return nil, err + } + + return models.NewRecordFromNullStringMap(collection, row), nil +} + +// FindRecordsByIds finds all Record models by the provided ids. +// If no records are found, returns an empty slice. +func (dao *Dao) FindRecordsByIds( + collection *models.Collection, + recordIds []string, + filter func(q *dbx.SelectQuery) error, +) ([]*models.Record, error) { + tableName := collection.Name + + query := dao.RecordQuery(collection). + AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...)) + + if filter != nil { + if err := filter(query); err != nil { + return nil, err + } + } + + rows := []dbx.NullStringMap{} + if err := query.All(&rows); err != nil { + return nil, err + } + + return models.NewRecordsFromNullStringMaps(collection, rows), nil +} + +// FindRecordsByExpr finds all records by the provided db expression. +// If no records are found, returns an empty slice. +// +// Example: +// expr := dbx.HashExp{"email": "test@example.com"} +// dao.FindRecordsByExpr(collection, expr) +func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) { + if expr == nil { + return nil, errors.New("Missing filter expression") + } + + rows := []dbx.NullStringMap{} + + err := dao.RecordQuery(collection). + AndWhere(expr). + All(&rows) + + if err != nil { + return nil, err + } + + return models.NewRecordsFromNullStringMaps(collection, rows), nil +} + +// FindFirstRecordByData returns the first found record matching +// the provided key-value pair. +func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) { + row := dbx.NullStringMap{} + + err := dao.RecordQuery(collection). + AndWhere(dbx.HashExp{key: value}). + Limit(1). + One(row) + + if err != nil { + return nil, err + } + + return models.NewRecordFromNullStringMap(collection, row), nil +} + +// IsRecordValueUnique checks if the provided key-value pair is a unique Record value. +// +// NB! Array values (eg. from multiple select fields) are matched +// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness +// depends on the elements order. Or in other words the following values +// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}` +func (dao *Dao) IsRecordValueUnique( + collection *models.Collection, + key string, + value any, + excludeId string, +) bool { + var exists bool + + var normalizedVal any + switch val := value.(type) { + case []string: + normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...) + case []any: + normalizedVal = append(types.JsonArray{}, val...) + default: + normalizedVal = val + } + + err := dao.RecordQuery(collection). + Select("count(*)"). + AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})). + AndWhere(dbx.HashExp{key: normalizedVal}). + Limit(1). + Row(&exists) + + return err == nil && !exists +} + +// FindUserRelatedRecords returns all records that has a reference +// to the provided User model (via the user shema field). +func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) { + collections, err := dao.FindCollectionsWithUserFields() + if err != nil { + return nil, err + } + + result := []*models.Record{} + for _, collection := range collections { + userFields := []*schema.SchemaField{} + + // prepare fields options + if err := collection.Schema.InitFieldsOptions(); err != nil { + return nil, err + } + + // extract user fields + for _, field := range collection.Schema.Fields() { + if field.Type == schema.FieldTypeUser { + userFields = append(userFields, field) + } + } + + // fetch records associated to the user + exprs := []dbx.Expression{} + for _, field := range userFields { + exprs = append(exprs, dbx.HashExp{field.Name: user.Id}) + } + rows := []dbx.NullStringMap{} + if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil { + return nil, err + } + records := models.NewRecordsFromNullStringMaps(collection, rows) + + result = append(result, records...) + } + + return result, nil +} + +// SaveRecord upserts the provided Record model. +func (dao *Dao) SaveRecord(record *models.Record) error { + return dao.Save(record) +} + +// DeleteRecord deletes the provided Record model. +// +// This method will also cascade the delete operation to all linked +// relational records (delete or set to NULL, depending on the rel settings). +// +// The delete operation may fail if the record is part of a required +// reference in another record (aka. cannot be deleted or set to NULL). +func (dao *Dao) DeleteRecord(record *models.Record) error { + // check for references + // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction + refs, err := dao.FindCollectionReferences(record.Collection(), "") + if err != nil { + return err + } + + // check if related records has to be deleted (if `CascadeDelete` is set) + // OR + // just unset the record id from any relation field values (if they are not required) + // ----------------------------------------------------------- + return dao.RunInTransaction(func(txDao *Dao) error { + for refCollection, fields := range refs { + for _, field := range fields { + options, _ := field.Options.(*schema.RelationOptions) + + rows := []dbx.NullStringMap{} + + // note: the select is not using the transaction dao to prevent SQLITE_LOCKED error when mixing read&write in a single transaction + err := dao.RecordQuery(refCollection). + AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})). + AndWhere(dbx.Like(field.Name, record.Id).Match(true, true)). + All(&rows) + if err != nil { + return err + } + + refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows) + for _, refRecord := range refRecords { + ids := refRecord.GetStringSliceDataValue(field.Name) + + // unset the record id + for i := len(ids) - 1; i >= 0; i-- { + if ids[i] == record.Id { + ids = append(ids[:i], ids[i+1:]...) + break + } + } + + // cascade delete the reference + // (only if there are no other active references in case of multiple select) + if options.CascadeDelete && len(ids) == 0 { + if err := txDao.DeleteRecord(refRecord); err != nil { + return err + } + // no further action are needed (the reference is deleted) + continue + } + + if field.Required && len(ids) == 0 { + return fmt.Errorf("The record cannot be deleted because it is part of a required reference in record %s (%s collection).", refRecord.Id, refCollection.Name) + } + + // save the reference changes + refRecord.SetDataValue(field.Name, field.PrepareValue(ids)) + if err := txDao.SaveRecord(refRecord); err != nil { + return err + } + } + } + } + + return txDao.Delete(record) + }) +} + +// SyncRecordTableSchema compares the two provided collections +// and applies the necessary related record table changes. +// +// If `oldCollection` is null, then only `newCollection` is used to create the record table. +func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error { + // create + if oldCollection == nil { + cols := map[string]string{ + schema.ReservedFieldNameId: "TEXT PRIMARY KEY", + schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`, + schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`, + } + + tableName := newCollection.Name + + // add schema field definitions + for _, field := range newCollection.Schema.Fields() { + cols[field.Name] = field.ColDefinition() + } + + // create table + _, tableErr := dao.DB().CreateTable(tableName, cols).Execute() + if tableErr != nil { + return tableErr + } + + // add index on the base `created` column + _, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute() + if indexErr != nil { + return indexErr + } + + return nil + } + + // update + return dao.RunInTransaction(func(txDao *Dao) error { + oldTableName := oldCollection.Name + newTableName := newCollection.Name + oldSchema := oldCollection.Schema + newSchema := newCollection.Schema + + // check for renamed table + if strings.ToLower(oldTableName) != strings.ToLower(newTableName) { + _, err := dao.DB().RenameTable(oldTableName, newTableName).Execute() + if err != nil { + return err + } + } + + // check for deleted columns + for _, oldField := range oldSchema.Fields() { + if f := newSchema.GetFieldById(oldField.Id); f != nil { + continue // exist + } + + _, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute() + if err != nil { + return err + } + } + + // check for new or renamed columns + for _, field := range newSchema.Fields() { + oldField := oldSchema.GetFieldById(field.Id) + if oldField != nil { + // rename + _, err := txDao.DB().RenameColumn(newTableName, oldField.Name, field.Name).Execute() + if err != nil { + return err + } + } else { + // add + _, err := txDao.DB().AddColumn(newTableName, field.Name, field.ColDefinition()).Execute() + if err != nil { + return err + } + } + } + + return nil + }) +} diff --git a/daos/record_expand.go b/daos/record_expand.go new file mode 100644 index 000000000..9a023bc55 --- /dev/null +++ b/daos/record_expand.go @@ -0,0 +1,155 @@ +package daos + +import ( + "errors" + "fmt" + "strings" + + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" +) + +// MaxExpandDepth specifies the max allowed nested expand depth path. +const MaxExpandDepth = 6 + +// ExpandFetchFunc defines the function that is used to fetch the expanded relation records. +type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) + +// ExpandRecord expands the relations of a single Record model. +func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) error { + return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc) +} + +// ExpandRecords expands the relations of the provided Record models list. +func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) error { + normalized := normalizeExpands(expands) + + for _, expand := range normalized { + if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil { + return err + } + } + + return nil +} + +// notes: +// - fetchFunc must be non-nil func +// - all records are expected to be from the same collection +// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path +func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error { + if fetchFunc == nil { + return errors.New("Relation records fetchFunc is not set.") + } + + if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 { + return nil + } + + parts := strings.SplitN(expandPath, ".", 2) + + // extract the relation field (if exist) + mainCollection := records[0].Collection() + relField := mainCollection.Schema.GetFieldByName(parts[0]) + if relField == nil { + return fmt.Errorf("Couldn't find field %q in collection %q.", parts[0], mainCollection.Name) + } + relField.InitOptions() + relFieldOptions, _ := relField.Options.(*schema.RelationOptions) + + relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId) + if err != nil { + return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId) + } + + // extract the id of the relations to expand + relIds := []string{} + for _, record := range records { + relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...) + } + + // fetch rels + rels, relsErr := fetchFunc(relCollection, relIds) + if relsErr != nil { + return relsErr + } + + // expand nested fields + if len(parts) > 1 { + err := dao.expandRecords(rels, parts[1], fetchFunc, recursionLevel+1) + if err != nil { + return err + } + } + + // reindex with the rel id + indexedRels := map[string]*models.Record{} + for _, rel := range rels { + indexedRels[rel.GetId()] = rel + } + + for _, model := range records { + relIds := model.GetStringSliceDataValue(relField.Name) + + validRels := []*models.Record{} + for _, id := range relIds { + if rel, ok := indexedRels[id]; ok { + validRels = append(validRels, rel) + } + } + + if len(validRels) == 0 { + continue // no valid relations + } + + expandData := model.GetExpand() + + // normalize and set the expanded relations + if relFieldOptions.MaxSelect == 1 { + expandData[relField.Name] = validRels[0] + } else { + expandData[relField.Name] = validRels + } + model.SetExpand(expandData) + } + + return nil +} + +// 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{} + for _, p := range paths { + p := strings.ReplaceAll(p, " ", "") // replace spaces + p = strings.Trim(p, ".") // trim incomplete paths + if p == "" { + continue + } + normalized = append(normalized, p) + } + + // merge containing paths + for i, p1 := range normalized { + var skip bool + for j, p2 := range normalized { + if i == j { + continue + } + if strings.HasPrefix(p2, p1+".") { + // skip because there is more detailed expand path + skip = true + break + } + } + if !skip { + result = append(result, p1) + } + } + + return list.ToUniqueStringSlice(result) +} diff --git a/daos/record_expand_test.go b/daos/record_expand_test.go new file mode 100644 index 000000000..d85b996f5 --- /dev/null +++ b/daos/record_expand_test.go @@ -0,0 +1,258 @@ +package daos_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" +) + +func TestExpandRecords(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + col, _ := app.Dao().FindCollectionByNameOrId("demo4") + + scenarios := []struct { + recordIds []string + expands []string + fetchFunc daos.ExpandFetchFunc + expectExpandProps int + expectError bool + }{ + // empty records + { + []string{}, + []string{"onerel", "manyrels.onerel.manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + false, + }, + // empty expand + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, + []string{}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + false, + }, + // empty fetchFunc + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, + []string{"onerel", "manyrels.onerel.manyrels"}, + nil, + 0, + true, + }, + // fetchFunc with error + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, + []string{"onerel", "manyrels.onerel.manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return nil, errors.New("test error") + }, + 0, + true, + }, + // invalid missing first level expand + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, + []string{"invalid"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + true, + }, + // invalid missing second level expand + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"}, + []string{"manyrels.invalid"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + true, + }, + // expand normalizations + { + []string{ + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + "df55c8ff-45ef-4c82-8aed-6e2183fe1125", + "b84cd893-7119-43c9-8505-3c4e22da28a9", + "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", + }, + []string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 9, + false, + }, + // single expand + { + []string{ + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + "df55c8ff-45ef-4c82-8aed-6e2183fe1125", + "b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels + "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels + }, + []string{"manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 2, + false, + }, + // maxExpandDepth reached + { + []string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"}, + []string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 6, + false, + }, + } + + for i, s := range scenarios { + ids := list.ToUniqueStringSlice(s.recordIds) + records, _ := app.Dao().FindRecordsByIds(col, ids, nil) + err := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc) + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + encoded, _ := json.Marshal(records) + encodedStr := string(encoded) + totalExpandProps := strings.Count(encodedStr, "@expand") + + if s.expectExpandProps != totalExpandProps { + t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps) + } + } +} + +func TestExpandRecord(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + col, _ := app.Dao().FindCollectionByNameOrId("demo4") + + scenarios := []struct { + recordId string + expands []string + fetchFunc daos.ExpandFetchFunc + expectExpandProps int + expectError bool + }{ + // empty expand + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + false, + }, + // empty fetchFunc + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"onerel", "manyrels.onerel.manyrels"}, + nil, + 0, + true, + }, + // fetchFunc with error + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"onerel", "manyrels.onerel.manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return nil, errors.New("test error") + }, + 0, + true, + }, + // invalid missing first level expand + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"invalid"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + true, + }, + // invalid missing second level expand + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"manyrels.invalid"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 0, + true, + }, + // expand normalizations + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 3, + false, + }, + // single expand + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 1, + false, + }, + // maxExpandDepth reached + { + "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", + []string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"}, + func(c *models.Collection, ids []string) ([]*models.Record, error) { + return app.Dao().FindRecordsByIds(c, ids, nil) + }, + 6, + false, + }, + } + + for i, s := range scenarios { + record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId) + err := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc) + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + encoded, _ := json.Marshal(record) + encodedStr := string(encoded) + totalExpandProps := strings.Count(encodedStr, "@expand") + + if s.expectExpandProps != totalExpandProps { + t.Errorf("(%d) Expected %d @expand props in %v, got %d", i, s.expectExpandProps, encodedStr, totalExpandProps) + } + } +} diff --git a/daos/record_test.go b/daos/record_test.go new file mode 100644 index 000000000..6b3f30f07 --- /dev/null +++ b/daos/record_test.go @@ -0,0 +1,473 @@ +package daos_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" +) + +func TestRecordQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name) + + sql := app.Dao().RecordQuery(collection).Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestFindRecordById(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + scenarios := []struct { + id string + filter func(q *dbx.SelectQuery) error + expectError bool + }{ + {"00000000-bafd-48f7-b8b7-090638afe209", nil, true}, + {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false}, + {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error { + q.AndWhere(dbx.HashExp{"title": "missing"}) + return nil + }, true}, + {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error { + return errors.New("test error") + }, true}, + {"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error { + q.AndWhere(dbx.HashExp{"title": "lorem"}) + return nil + }, false}, + } + + for i, scenario := range scenarios { + record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if record != nil && record.Id != scenario.id { + t.Errorf("(%d) Expected record with id %s, got %s", i, scenario.id, record.Id) + } + } +} + +func TestFindRecordsByIds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + scenarios := []struct { + ids []string + filter func(q *dbx.SelectQuery) error + expectTotal int + expectError bool + }{ + {[]string{}, nil, 0, false}, + {[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false}, + {[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false}, + { + []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"}, + nil, + 2, + false, + }, + { + []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"}, + func(q *dbx.SelectQuery) error { + return errors.New("test error") + }, + 0, + true, + }, + { + []string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"}, + func(q *dbx.SelectQuery) error { + q.AndWhere(dbx.Like("title", "test").Match(true, true)) + return nil + }, + 1, + false, + }, + } + + for i, scenario := range scenarios { + records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if len(records) != scenario.expectTotal { + t.Errorf("(%d) Expected %d records, got %d", i, scenario.expectTotal, len(records)) + continue + } + + for _, r := range records { + if !list.ExistInSlice(r.Id, scenario.ids) { + t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.ids) + } + } + } +} + +func TestFindRecordsByExpr(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + scenarios := []struct { + expression dbx.Expression + expectIds []string + expectError bool + }{ + { + nil, + []string{}, + true, + }, + { + dbx.HashExp{"id": 123}, + []string{}, + false, + }, + { + dbx.Like("title", "test").Match(true, true), + []string{ + "848a1dea-5ddd-42d6-a00d-030547bffcfe", + "577bd676-aacb-4072-b7da-99d00ee210a4", + }, + false, + }, + } + + for i, scenario := range scenarios { + records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if len(records) != len(scenario.expectIds) { + t.Errorf("(%d) Expected %d records, got %d", i, len(scenario.expectIds), len(records)) + continue + } + + for _, r := range records { + if !list.ExistInSlice(r.Id, scenario.expectIds) { + t.Errorf("(%d) Couldn't find id %s in %v", i, r.Id, scenario.expectIds) + } + } + } +} + +func TestFindFirstRecordByData(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + scenarios := []struct { + key string + value any + expectId string + expectError bool + }{ + { + "", + "848a1dea-5ddd-42d6-a00d-030547bffcfe", + "", + true, + }, + { + "id", + "invalid", + "", + true, + }, + { + "id", + "848a1dea-5ddd-42d6-a00d-030547bffcfe", + "848a1dea-5ddd-42d6-a00d-030547bffcfe", + false, + }, + { + "title", + "lorem", + "b5c2ffc2-bafd-48f7-b8b7-090638afe209", + false, + }, + } + + for i, scenario := range scenarios { + record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + continue + } + + if !scenario.expectError && record.Id != scenario.expectId { + t.Errorf("(%d) Expected record with id %s, got %v", i, scenario.expectId, record.Id) + } + } +} + +func TestIsRecordValueUnique(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo4") + + testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125" + testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9" + + scenarios := []struct { + key string + value any + excludeId string + expected bool + }{ + {"", "", "", false}, + {"missing", "unique", "", false}, + {"title", "unique", "", true}, + {"title", "demo1", "", false}, + {"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true}, + {"manyrels", []string{testManyRelsId2}, "", false}, + {"manyrels", []any{testManyRelsId2}, "", false}, + // with exclude + {"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true}, + // reverse order + {"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true}, + } + + for i, scenario := range scenarios { + result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId) + + if result != scenario.expected { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) + } + } +} + +func TestFindUserRelatedRecords(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + u0 := &models.User{} + u1, _ := app.Dao().FindUserByEmail("test3@example.com") + u2, _ := app.Dao().FindUserByEmail("test2@example.com") + + scenarios := []struct { + user *models.User + expectedIds []string + }{ + {u0, []string{}}, + {u1, []string{ + "94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2 + "fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile + }}, + {u2, []string{ + "b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile + }}, + } + + for i, scenario := range scenarios { + records, err := app.Dao().FindUserRelatedRecords(scenario.user) + if err != nil { + t.Fatal(err) + } + + if len(records) != len(scenario.expectedIds) { + t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records) + continue + } + + for _, r := range records { + if !list.ExistInSlice(r.Id, scenario.expectedIds) { + t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds) + } + } + } +} + +func TestSaveRecord(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + + // create + // --- + r1 := models.NewRecord(collection) + r1.SetDataValue("title", "test_new") + err1 := app.Dao().SaveRecord(r1) + if err1 != nil { + t.Fatal(err1) + } + newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new") + if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") { + t.Errorf("Expected to find record %v, got %v", r1, newR1) + } + + // update + // --- + r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209") + r2.SetDataValue("title", "test_update") + err2 := app.Dao().SaveRecord(r2) + if err2 != nil { + t.Fatal(err2) + } + newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update") + if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") { + t.Errorf("Expected to find record %v, got %v", r2, newR2) + } +} + +func TestDeleteRecord(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + demo, _ := app.Dao().FindCollectionByNameOrId("demo") + demo2, _ := app.Dao().FindCollectionByNameOrId("demo2") + + // delete unsaved record + // --- + rec1 := models.NewRecord(demo) + err1 := app.Dao().DeleteRecord(rec1) + if err1 == nil { + t.Fatal("(rec1) Didn't expect to succeed deleting new record") + } + + // delete existing record while being part of a non-cascade required relation + // --- + rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe") + err2 := app.Dao().DeleteRecord(rec2) + if err2 == nil { + t.Fatalf("(rec2) Expected error, got nil") + } + + // delete existing record + // --- + rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4") + err3 := app.Dao().DeleteRecord(rec3) + if err3 != nil { + t.Fatalf("(rec3) Expected nil, got error %v", err3) + } + + // check if it was really deleted + rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil) + if rec3 != nil { + t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3) + } + + // check if the operation cascaded + rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f") + if rel != nil { + t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel) + } +} + +func TestSyncRecordTableSchema(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + oldCollection, err := app.Dao().FindCollectionByNameOrId("demo") + if err != nil { + t.Fatal(err) + } + updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo") + updatedCollection.Name = "demo_renamed" + updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id) + updatedCollection.Schema.AddField( + &schema.SchemaField{ + Name: "new_field", + Type: schema.FieldTypeEmail, + }, + ) + updatedCollection.Schema.AddField( + &schema.SchemaField{ + Id: updatedCollection.Schema.GetFieldByName("title").Id, + Name: "title_renamed", + Type: schema.FieldTypeEmail, + }, + ) + + scenarios := []struct { + newCollection *models.Collection + oldCollection *models.Collection + expectedTableName string + expectedColumns []string + }{ + { + &models.Collection{ + Name: "new_table", + Schema: schema.NewSchema( + &schema.SchemaField{ + Name: "test", + Type: schema.FieldTypeText, + }, + ), + }, + nil, + "new_table", + []string{"id", "created", "updated", "test"}, + }, + // no changes + { + oldCollection, + oldCollection, + "demo", + []string{"id", "created", "updated", "title", "file"}, + }, + // renamed table, deleted column, renamed columnd and new column + { + updatedCollection, + oldCollection, + "demo_renamed", + []string{"id", "created", "updated", "title_renamed", "new_field"}, + }, + } + + for i, scenario := range scenarios { + err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection) + if err != nil { + t.Errorf("(%d) %v", i, err) + continue + } + + if !app.Dao().HasTable(scenario.newCollection.Name) { + t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name) + } + + cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name) + if len(cols) != len(scenario.expectedColumns) { + t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols) + } + + for _, c := range cols { + if !list.ExistInSlice(c, scenario.expectedColumns) { + t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns) + } + } + } +} diff --git a/daos/request.go b/daos/request.go new file mode 100644 index 000000000..6616b9f33 --- /dev/null +++ b/daos/request.go @@ -0,0 +1,70 @@ +package daos + +import ( + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/types" +) + +// RequestQuery returns a new Request logs select query. +func (dao *Dao) RequestQuery() *dbx.SelectQuery { + return dao.ModelQuery(&models.Request{}) +} + +// FindRequestById finds a single Request log by its id. +func (dao *Dao) FindRequestById(id string) (*models.Request, error) { + model := &models.Request{} + + err := dao.RequestQuery(). + AndWhere(dbx.HashExp{"id": id}). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + return model, nil +} + +type RequestsStatsItem struct { + Total int `db:"total" json:"total"` + Date types.DateTime `db:"date" json:"date"` +} + +// RequestsStats returns hourly grouped requests logs statistics. +func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) { + result := []*RequestsStatsItem{} + + query := dao.RequestQuery(). + Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date"). + GroupBy("date") + + if expr != nil { + query.AndWhere(expr) + } + + err := query.All(&result) + + return result, err +} + +// DeleteOldRequests delete all requests that are created before createdBefore. +func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error { + m := models.Request{} + tableName := m.TableName() + + formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout) + expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate}) + + _, err := dao.DB().Delete(tableName, expr).Execute() + + return err +} + +// SaveRequest upserts the provided Request model. +func (dao *Dao) SaveRequest(request *models.Request) error { + return dao.Save(request) +} diff --git a/daos/request_test.go b/daos/request_test.go new file mode 100644 index 000000000..97a1c28e0 --- /dev/null +++ b/daos/request_test.go @@ -0,0 +1,148 @@ +package daos_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestRequestQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + expected := "SELECT {{_requests}}.* FROM `_requests`" + + sql := app.Dao().RequestQuery().Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestFindRequestById(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + tests.MockRequestLogsData(app) + + scenarios := []struct { + id string + expectError bool + }{ + {"", true}, + {"invalid", true}, + {"00000000-9f38-44fb-bf82-c8f53b310d91", true}, + {"873f2133-9f38-44fb-bf82-c8f53b310d91", false}, + } + + for i, scenario := range scenarios { + admin, err := app.LogsDao().FindRequestById(scenario.id) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if admin != nil && admin.Id != scenario.id { + t.Errorf("(%d) Expected admin with id %s, got %s", i, scenario.id, admin.Id) + } + } +} + +func TestRequestsStats(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + tests.MockRequestLogsData(app) + + expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]` + + now := time.Now().UTC().Format(types.DefaultDateLayout) + exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now}) + result, err := app.LogsDao().RequestsStats(exp) + if err != nil { + t.Fatal(err) + } + + encoded, _ := json.Marshal(result) + if string(encoded) != expected { + t.Fatalf("Expected %s, got %s", expected, string(encoded)) + } +} + +func TestDeleteOldRequests(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + tests.MockRequestLogsData(app) + + scenarios := []struct { + date string + expectedTotal int + }{ + {"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time + {"2022-05-01 11:00:00.000", 1}, // only 1 request should have left + {"2022-05-03 11:00:00.000", 0}, // no more requests should have left + {"2022-05-04 11:00:00.000", 0}, // no more requests should have left + } + + for i, scenario := range scenarios { + date, dateErr := time.Parse(types.DefaultDateLayout, scenario.date) + if dateErr != nil { + t.Errorf("(%d) Date error %v", i, dateErr) + } + + deleteErr := app.LogsDao().DeleteOldRequests(date) + if deleteErr != nil { + t.Errorf("(%d) Delete error %v", i, deleteErr) + } + + // check total remaining requests + var total int + countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total) + if countErr != nil { + t.Errorf("(%d) Count error %v", i, countErr) + } + + if total != scenario.expectedTotal { + t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total) + } + } +} + +func TestSaveRequest(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + tests.MockRequestLogsData(app) + + // create new request + newRequest := &models.Request{} + newRequest.Method = "get" + newRequest.Meta = types.JsonMap{} + createErr := app.LogsDao().SaveRequest(newRequest) + if createErr != nil { + t.Fatal(createErr) + } + + // check if it was really created + existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id) + if fetchErr != nil { + t.Fatal(fetchErr) + } + + existingRequest.Method = "post" + updateErr := app.LogsDao().SaveRequest(existingRequest) + if updateErr != nil { + t.Fatal(updateErr) + } + // refresh instance to check if it was really updated + existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id) + if existingRequest.Method != "post" { + t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method) + } +} diff --git a/daos/table.go b/daos/table.go new file mode 100644 index 000000000..e950f3a2e --- /dev/null +++ b/daos/table.go @@ -0,0 +1,37 @@ +package daos + +import ( + "github.com/pocketbase/dbx" +) + +// HasTable checks if a table with the provided name exists (case insensitive). +func (dao *Dao) HasTable(tableName string) bool { + var exists bool + + err := dao.DB().Select("count(*)"). + From("sqlite_schema"). + AndWhere(dbx.HashExp{"type": "table"}). + AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:tableName})", dbx.Params{"tableName": tableName})). + Limit(1). + Row(&exists) + + return err == nil && exists +} + +// GetTableColumns returns all column names of a single table by its name. +func (dao *Dao) GetTableColumns(tableName string) ([]string, error) { + columns := []string{} + + err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})"). + Bind(dbx.Params{"tableName": tableName}). + Column(&columns) + + return columns, err +} + +// DeleteTable drops the specified table. +func (dao *Dao) DeleteTable(tableName string) error { + _, err := dao.DB().DropTable(tableName).Execute() + + return err +} diff --git a/daos/table_test.go b/daos/table_test.go new file mode 100644 index 000000000..4c6571cc9 --- /dev/null +++ b/daos/table_test.go @@ -0,0 +1,81 @@ +package daos_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" +) + +func TestHasTable(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + tableName string + expected bool + }{ + {"", false}, + {"test", false}, + {"_admins", true}, + {"demo3", true}, + {"DEMO3", true}, // table names are case insensitives by default + } + + for i, scenario := range scenarios { + result := app.Dao().HasTable(scenario.tableName) + if result != scenario.expected { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) + } + } +} + +func TestGetTableColumns(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + tableName string + expected []string + }{ + {"", nil}, + {"_params", []string{"id", "key", "value", "created", "updated"}}, + } + + for i, scenario := range scenarios { + columns, _ := app.Dao().GetTableColumns(scenario.tableName) + + if len(columns) != len(scenario.expected) { + t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expected, columns) + } + + for _, c := range columns { + if !list.ExistInSlice(c, scenario.expected) { + t.Errorf("(%d) Didn't expect column %s", i, c) + } + } + } +} + +func TestDeleteTable(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + tableName string + expectError bool + }{ + {"", true}, + {"test", true}, + {"_admins", false}, + {"demo3", false}, + } + + for i, scenario := range scenarios { + err := app.Dao().DeleteTable(scenario.tableName) + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr %v, got %v", i, scenario.expectError, hasErr) + } + } +} diff --git a/daos/user.go b/daos/user.go new file mode 100644 index 000000000..29fa8f8ac --- /dev/null +++ b/daos/user.go @@ -0,0 +1,281 @@ +package daos + +import ( + "database/sql" + "errors" + "fmt" + "log" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/security" +) + +// UserQuery returns a new User model select query. +func (dao *Dao) UserQuery() *dbx.SelectQuery { + return dao.ModelQuery(&models.User{}) +} + +// LoadProfile loads the profile record associated to the provided user. +func (dao *Dao) LoadProfile(user *models.User) error { + collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName) + if err != nil { + return err + } + + profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id) + if err != nil && err != sql.ErrNoRows { + return err + } + + user.Profile = profile + + return nil +} + +// LoadProfiles loads the profile records associated to the provied users list. +func (dao *Dao) LoadProfiles(users []*models.User) error { + collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName) + if err != nil { + return err + } + + // extract user ids + ids := []string{} + usersMap := map[string]*models.User{} + for _, user := range users { + ids = append(ids, user.Id) + usersMap[user.Id] = user + } + + profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{ + models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids), + }) + if err != nil { + return err + } + + // populate each user.Profile member + for _, profile := range profiles { + userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName) + user, ok := usersMap[userId] + if !ok { + continue + } + user.Profile = profile + } + + return nil +} + +// FindUserById finds a single User model by its id. +// +// This method also auto loads the related user profile record +// into the found model. +func (dao *Dao) FindUserById(id string) (*models.User, error) { + model := &models.User{} + + err := dao.UserQuery(). + AndWhere(dbx.HashExp{"id": id}). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + // try to load the user profile (if exist) + if err := dao.LoadProfile(model); err != nil { + log.Println(err) + } + + return model, nil +} + +// FindUserByEmail finds a single User model by its email address. +// +// This method also auto loads the related user profile record +// into the found model. +func (dao *Dao) FindUserByEmail(email string) (*models.User, error) { + model := &models.User{} + + err := dao.UserQuery(). + AndWhere(dbx.HashExp{"email": email}). + Limit(1). + One(model) + + if err != nil { + return nil, err + } + + // try to load the user profile (if exist) + if err := dao.LoadProfile(model); err != nil { + log.Println(err) + } + + return model, nil +} + +// FindUserByToken finds the user associated with the provided JWT token. +// Returns an error if the JWT token is invalid or expired. +// +// This method also auto loads the related user profile record +// into the found model. +func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) { + unverifiedClaims, err := security.ParseUnverifiedJWT(token) + if err != nil { + return nil, err + } + + // check required claims + id, _ := unverifiedClaims["id"].(string) + if id == "" { + return nil, errors.New("Missing or invalid token claims.") + } + + user, err := dao.FindUserById(id) + if err != nil || user == nil { + return nil, err + } + + verificationKey := user.TokenKey + baseTokenKey + + // verify token signature + if _, err := security.ParseJWT(token, verificationKey); err != nil { + return nil, err + } + + return user, nil +} + +// IsUserEmailUnique checks if the provided email address is not +// already in use by other users. +func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool { + if email == "" { + return false + } + + var exists bool + err := dao.UserQuery(). + Select("count(*)"). + AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})). + AndWhere(dbx.HashExp{"email": email}). + Limit(1). + Row(&exists) + + return err == nil && !exists +} + +// DeleteUser deletes the provided User model. +// +// This method will also cascade the delete operation to all +// Record models that references the provided User model +// (delete or set to NULL, depending on the related user shema field settings). +// +// The delete operation may fail if the user is part of a required +// reference in another Record model (aka. cannot be deleted or set to NULL). +func (dao *Dao) DeleteUser(user *models.User) error { + // fetch related records + // note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction + relatedRecords, err := dao.FindUserRelatedRecords(user) + if err != nil { + return err + } + + return dao.RunInTransaction(func(txDao *Dao) error { + // check if related records has to be deleted (if `CascadeDelete` is set) + // OR + // just unset the user related fields (if they are not required) + // ----------------------------------------------------------- + recordsLoop: + for _, record := range relatedRecords { + var needSave bool + + for _, field := range record.Collection().Schema.Fields() { + if field.Type != schema.FieldTypeUser { + continue // not a user field + } + + ids := record.GetStringSliceDataValue(field.Name) + + // unset the user id + for i := len(ids) - 1; i >= 0; i-- { + if ids[i] == user.Id { + ids = append(ids[:i], ids[i+1:]...) + break + } + } + + options, _ := field.Options.(*schema.UserOptions) + + // cascade delete + // (only if there are no other user references in case of multiple select) + if options.CascadeDelete && len(ids) == 0 { + if err := txDao.DeleteRecord(record); err != nil { + return err + } + // no need to further iterate the user fields (the record is deleted) + continue recordsLoop + } + + if field.Required && len(ids) == 0 { + return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name) + } + + // apply the reference changes + record.SetDataValue(field.Name, field.PrepareValue(ids)) + needSave = true + } + + if needSave { + if err := txDao.SaveRecord(record); err != nil { + return err + } + } + } + // ----------------------------------------------------------- + + return txDao.Delete(user) + }) +} + +// SaveUser upserts the provided User model. +// +// An empty profile record will be created if the user +// doesn't have a profile record set yet. +func (dao *Dao) SaveUser(user *models.User) error { + profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName) + if err != nil { + return err + } + + // fetch the related user profile record (if exist) + var userProfile *models.Record + if user.HasId() { + userProfile, _ = dao.FindFirstRecordByData( + profileCollection, + models.ProfileCollectionUserFieldName, + user.Id, + ) + } + + return dao.RunInTransaction(func(txDao *Dao) error { + if err := txDao.Save(user); err != nil { + return err + } + + // create default/empty profile record if doesn't exist + if userProfile == nil { + userProfile = models.NewRecord(profileCollection) + userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id) + if err := txDao.Save(userProfile); err != nil { + return err + } + user.Profile = userProfile + } + + return nil + }) +} diff --git a/daos/user_test.go b/daos/user_test.go new file mode 100644 index 000000000..8d818dd20 --- /dev/null +++ b/daos/user_test.go @@ -0,0 +1,274 @@ +package daos_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestUserQuery(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + expected := "SELECT {{_users}}.* FROM `_users`" + + sql := app.Dao().UserQuery().Build().SQL() + if sql != expected { + t.Errorf("Expected sql %s, got %s", expected, sql) + } +} + +func TestLoadProfile(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // try to load missing profile (shouldn't return an error) + // --- + newUser := &models.User{} + err1 := app.Dao().LoadProfile(newUser) + if err1 != nil { + t.Fatalf("Expected nil, got error %v", err1) + } + + // try to load existing profile + // --- + existingUser, _ := app.Dao().FindUserByEmail("test@example.com") + existingUser.Profile = nil // reset + + err2 := app.Dao().LoadProfile(existingUser) + if err2 != nil { + t.Fatal(err2) + } + + if existingUser.Profile == nil { + t.Fatal("Expected user profile to be loaded, got nil") + } + + if existingUser.Profile.GetStringDataValue("name") != "test" { + t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name")) + } +} + +func TestLoadProfiles(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + u0 := &models.User{} + u1, _ := app.Dao().FindUserByEmail("test@example.com") + u2, _ := app.Dao().FindUserByEmail("test2@example.com") + + users := []*models.User{u0, u1, u2} + + err := app.Dao().LoadProfiles(users) + if err != nil { + t.Fatal(err) + } + + if u0.Profile != nil { + t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile) + } + if u1.Profile == nil { + t.Errorf("Expected profile to be set for u1, got nil") + } + if u2.Profile == nil { + t.Errorf("Expected profile to be set for u2, got nil") + } +} + +func TestFindUserById(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + id string + expectError bool + }{ + {"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true}, + {"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false}, + } + + for i, scenario := range scenarios { + user, err := app.Dao().FindUserById(scenario.id) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + } + + if user != nil && user.Id != scenario.id { + t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id) + } + } +} + +func TestFindUserByEmail(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + email string + expectError bool + }{ + {"invalid", true}, + {"missing@example.com", true}, + {"test@example.com", false}, + } + + for i, scenario := range scenarios { + user, err := app.Dao().FindUserByEmail(scenario.email) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + continue + } + + if !scenario.expectError && user.Email != scenario.email { + t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email) + } + } +} + +func TestFindUserByToken(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + token string + baseKey string + expectedEmail string + expectError bool + }{ + // invalid base key (password reset key for auth token) + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + app.Settings().UserPasswordResetToken.Secret, + "", + true, + }, + // expired token + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw", + app.Settings().UserAuthToken.Secret, + "", + true, + }, + // valid token + { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + app.Settings().UserAuthToken.Secret, + "test@example.com", + false, + }, + } + + for i, scenario := range scenarios { + user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey) + + hasErr := err != nil + if hasErr != scenario.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err) + continue + } + + if !scenario.expectError && user.Email != scenario.expectedEmail { + t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email) + } + } +} + +func TestIsUserEmailUnique(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + email string + excludeId string + expected bool + }{ + {"", "", false}, + {"test@example.com", "", false}, + {"new@example.com", "", true}, + {"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true}, + } + + for i, scenario := range scenarios { + result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId) + if result != scenario.expected { + t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result) + } + } +} + +func TestDeleteUser(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // try to delete unsaved user + // --- + err1 := app.Dao().DeleteUser(&models.User{}) + if err1 == nil { + t.Fatal("Expected error, got nil") + } + + // try to delete existing user + // --- + user, _ := app.Dao().FindUserByEmail("test3@example.com") + err2 := app.Dao().DeleteUser(user) + if err2 != nil { + t.Fatalf("Expected nil, got error %v", err2) + } + + // check if the delete operation was cascaded to the profiles collection (record delete) + profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName) + profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil) + if profile != nil { + t.Fatalf("Expected user profile to be deleted, got %v", profile) + } + + // check if delete operation was cascaded to the related demo2 collection (null set) + demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2") + record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil) + if record == nil { + t.Fatal("Expected to found related record, got nil") + } + if record.GetStringDataValue("user") != "" { + t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user")) + } +} + +func TestSaveUser(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create + // --- + u1 := &models.User{} + u1.Email = "new@example.com" + u1.SetPassword("123456") + err1 := app.Dao().SaveUser(u1) + if err1 != nil { + t.Fatal(err1) + } + u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com") + if refreshErr1 != nil { + t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1) + } + if u1.Profile == nil { + t.Fatalf("Expected creating a user to create also an empty profile record") + } + + // update + // --- + u2, _ := app.Dao().FindUserByEmail("test@example.com") + u2.Email = "test_update@example.com" + err2 := app.Dao().SaveUser(u2) + if err2 != nil { + t.Fatal(err2) + } + u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com") + if u2 == nil { + t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2) + } +} diff --git a/examples/base/main.go b/examples/base/main.go new file mode 100644 index 000000000..04b452be0 --- /dev/null +++ b/examples/base/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +func main() { + app := pocketbase.New() + + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + // serves static files from the provided public dir (if exists) + subFs := echo.MustSubFS(e.Router.Filesystem, "pb_public") + e.Router.GET("/*", apis.StaticDirectoryHandler(subFs, false)) + + return nil + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/forms/admin_login.go b/forms/admin_login.go new file mode 100644 index 000000000..842552ed4 --- /dev/null +++ b/forms/admin_login.go @@ -0,0 +1,50 @@ +package forms + +import ( + "errors" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" +) + +// AdminLogin defines an admin email/pass login form. +type AdminLogin struct { + app core.App + + Email string `form:"email" json:"email"` + Password string `form:"password" json:"password"` +} + +// NewAdminLogin creates new admin login form for the provided app. +func NewAdminLogin(app core.App) *AdminLogin { + return &AdminLogin{app: app} +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *AdminLogin) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email), + validation.Field(&form.Password, validation.Required, validation.Length(1, 255)), + ) +} + +// Submit validates and submits the admin form. +// On success returns the authorized admin model. +func (form *AdminLogin) Submit() (*models.Admin, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + admin, err := form.app.Dao().FindAdminByEmail(form.Email) + if err != nil { + return nil, err + } + + if admin.ValidatePassword(form.Password) { + return admin, nil + } + + return nil, errors.New("Invalid login credentials.") +} diff --git a/forms/admin_login_test.go b/forms/admin_login_test.go new file mode 100644 index 000000000..9e5d09c8b --- /dev/null +++ b/forms/admin_login_test.go @@ -0,0 +1,80 @@ +package forms_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" +) + +func TestAdminLoginValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + form := forms.NewAdminLogin(app) + + scenarios := []struct { + email string + password string + expectError bool + }{ + {"", "", true}, + {"", "123", true}, + {"test@example.com", "", true}, + {"test", "123", true}, + {"test@example.com", "123", false}, + } + + for i, s := range scenarios { + form.Email = s.email + form.Password = s.password + + err := form.Validate() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} + +func TestAdminLoginSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + form := forms.NewAdminLogin(app) + + scenarios := []struct { + email string + password string + expectError bool + }{ + {"", "", true}, + {"", "1234567890", true}, + {"test@example.com", "", true}, + {"test", "1234567890", true}, + {"missing@example.com", "1234567890", true}, + {"test@example.com", "123456789", true}, + {"test@example.com", "1234567890", false}, + } + + for i, s := range scenarios { + form.Email = s.email + form.Password = s.password + + admin, err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + if !s.expectError && admin == nil { + t.Errorf("(%d) Expected admin model to be returned, got nil", i) + } + + if admin != nil && admin.Email != s.email { + t.Errorf("(%d) Expected admin with email %s to be returned, got %v", i, s.email, admin) + } + } +} diff --git a/forms/admin_password_reset_confirm.go b/forms/admin_password_reset_confirm.go new file mode 100644 index 000000000..759bc77d7 --- /dev/null +++ b/forms/admin_password_reset_confirm.go @@ -0,0 +1,76 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" +) + +// AdminPasswordResetConfirm defines an admin password reset confirmation form. +type AdminPasswordResetConfirm struct { + app core.App + + Token string `form:"token" json:"token"` + Password string `form:"password" json:"password"` + PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` +} + +// NewAdminPasswordResetConfirm creates new admin password reset confirmation form. +func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm { + return &AdminPasswordResetConfirm{ + app: app, + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *AdminPasswordResetConfirm) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), + validation.Field(&form.Password, validation.Required, validation.Length(10, 100)), + validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))), + ) +} + +func (form *AdminPasswordResetConfirm) checkToken(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + admin, err := form.app.Dao().FindAdminByToken( + v, + form.app.Settings().AdminPasswordResetToken.Secret, + ) + if err != nil || admin == nil { + return validation.NewError("validation_invalid_token", "Invalid or expired token.") + } + + return nil +} + +// Submit validates and submits the admin password reset confirmation form. +// On success returns the updated admin model associated to `form.Token`. +func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + admin, err := form.app.Dao().FindAdminByToken( + form.Token, + form.app.Settings().AdminPasswordResetToken.Secret, + ) + if err != nil { + return nil, err + } + + if err := admin.SetPassword(form.Password); err != nil { + return nil, err + } + + if err := form.app.Dao().SaveAdmin(admin); err != nil { + return nil, err + } + + return admin, nil +} diff --git a/forms/admin_password_reset_confirm_test.go b/forms/admin_password_reset_confirm_test.go new file mode 100644 index 000000000..ebdaed788 --- /dev/null +++ b/forms/admin_password_reset_confirm_test.go @@ -0,0 +1,120 @@ +package forms_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestAdminPasswordResetConfirmValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + form := forms.NewAdminPasswordResetConfirm(app) + + scenarios := []struct { + token string + password string + passwordConfirm string + expectError bool + }{ + {"", "", "", true}, + {"", "123", "", true}, + {"", "", "123", true}, + {"test", "", "", true}, + {"test", "123", "", true}, + {"test", "123", "123", true}, + { + // expired + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", + "1234567890", + "1234567890", + true, + }, + { + // valid + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw", + "1234567890", + "1234567890", + false, + }, + } + + for i, s := range scenarios { + form.Token = s.token + form.Password = s.password + form.PasswordConfirm = s.passwordConfirm + + err := form.Validate() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} + +func TestAdminPasswordResetConfirmSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + form := forms.NewAdminPasswordResetConfirm(app) + + scenarios := []struct { + token string + password string + passwordConfirm string + expectError bool + }{ + {"", "", "", true}, + {"", "123", "", true}, + {"", "", "123", true}, + {"test", "", "", true}, + {"test", "123", "", true}, + {"test", "123", "123", true}, + { + // expired + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA", + "1234567890", + "1234567890", + true, + }, + { + // valid + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw", + "1234567890", + "1234567890", + false, + }, + } + + for i, s := range scenarios { + form.Token = s.token + form.Password = s.password + form.PasswordConfirm = s.passwordConfirm + + admin, err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + if s.expectError { + continue + } + + claims, _ := security.ParseUnverifiedJWT(s.token) + tokenAdminId, _ := claims["id"] + + if admin.Id != tokenAdminId { + t.Errorf("(%d) Expected admin with id %s to be returned, got %v", i, tokenAdminId, admin) + } + + if !admin.ValidatePassword(form.Password) { + t.Errorf("(%d) Expected the admin password to have been updated to %q", i, form.Password) + } + } +} diff --git a/forms/admin_password_reset_request.go b/forms/admin_password_reset_request.go new file mode 100644 index 000000000..283b734e9 --- /dev/null +++ b/forms/admin_password_reset_request.go @@ -0,0 +1,70 @@ +package forms + +import ( + "errors" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/tools/types" +) + +// AdminPasswordResetRequest defines an admin password reset request form. +type AdminPasswordResetRequest struct { + app core.App + resendThreshold float64 + + Email string `form:"email" json:"email"` +} + +// NewAdminPasswordResetRequest creates new admin password reset request form. +func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest { + return &AdminPasswordResetRequest{ + app: app, + resendThreshold: 120, // 2 min + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +// +// This method doesn't verify that admin with `form.Email` exists (this is done on Submit). +func (form *AdminPasswordResetRequest) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.Email, + validation.Required, + validation.Length(1, 255), + is.Email, + ), + ) +} + +// Submit validates and submits the form. +// On success sends a password reset email to the `form.Email` admin. +func (form *AdminPasswordResetRequest) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + admin, err := form.app.Dao().FindAdminByEmail(form.Email) + if err != nil { + return err + } + + now := time.Now().UTC() + lastResetSentAt := admin.LastResetSentAt.Time() + if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold { + return errors.New("You have already requested a password reset.") + } + + if err := mails.SendAdminPasswordReset(form.app, admin); err != nil { + return err + } + + // update last sent timestamp + admin.LastResetSentAt = types.NowDateTime() + + return form.app.Dao().SaveAdmin(admin) +} diff --git a/forms/admin_password_reset_request_test.go b/forms/admin_password_reset_request_test.go new file mode 100644 index 000000000..245d48b0b --- /dev/null +++ b/forms/admin_password_reset_request_test.go @@ -0,0 +1,84 @@ +package forms_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" +) + +func TestAdminPasswordResetRequestValidate(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + form := forms.NewAdminPasswordResetRequest(testApp) + + scenarios := []struct { + email string + expectError bool + }{ + {"", true}, + {"", true}, + {"invalid", true}, + {"missing@example.com", false}, // doesn't check for existing admin + {"test@example.com", false}, + } + + for i, s := range scenarios { + form.Email = s.email + + err := form.Validate() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} + +func TestAdminPasswordResetRequestSubmit(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + form := forms.NewAdminPasswordResetRequest(testApp) + + scenarios := []struct { + email string + expectError bool + }{ + {"", true}, + {"", true}, + {"invalid", true}, + {"missing@example.com", true}, + {"test@example.com", false}, + {"test@example.com", true}, // already requested + } + + for i, s := range scenarios { + testApp.TestMailer.TotalSend = 0 // reset + form.Email = s.email + + adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email) + + err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + adminAfter, _ := testApp.Dao().FindAdminByEmail(s.email) + + if !s.expectError && (adminBefore.LastResetSentAt == adminAfter.LastResetSentAt || adminAfter.LastResetSentAt.IsZero()) { + t.Errorf("(%d) Expected admin.LastResetSentAt to change, got %q", i, adminAfter.LastResetSentAt) + } + + expectedMails := 1 + if s.expectError { + expectedMails = 0 + } + if testApp.TestMailer.TotalSend != expectedMails { + t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) + } + } +} diff --git a/forms/admin_upsert.go b/forms/admin_upsert.go new file mode 100644 index 000000000..0932a4e35 --- /dev/null +++ b/forms/admin_upsert.go @@ -0,0 +1,91 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" +) + +// AdminUpsert defines an admin upsert (create/update) form. +type AdminUpsert struct { + app core.App + admin *models.Admin + isCreate bool + + Avatar int `form:"avatar" json:"avatar"` + Email string `form:"email" json:"email"` + Password string `form:"password" json:"password"` + PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` +} + +// NewAdminUpsert creates new upsert form for the provided admin model +// (pass an empty admin model instance (`&models.Admin{}`) for create). +func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert { + form := &AdminUpsert{ + app: app, + admin: admin, + isCreate: !admin.HasId(), + } + + // load defaults + form.Avatar = admin.Avatar + form.Email = admin.Email + + return form +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *AdminUpsert) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.Avatar, + validation.Min(0), + validation.Max(9), + ), + validation.Field( + &form.Email, + validation.Required, + validation.Length(1, 255), + is.Email, + validation.By(form.checkUniqueEmail), + ), + validation.Field( + &form.Password, + validation.When(form.isCreate, validation.Required), + validation.Length(10, 100), + ), + validation.Field( + &form.PasswordConfirm, + validation.When(form.Password != "", validation.Required), + validation.By(validators.Compare(form.Password)), + ), + ) +} + +func (form *AdminUpsert) checkUniqueEmail(value any) error { + v, _ := value.(string) + + if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) { + return nil + } + + 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 { + if err := form.Validate(); err != nil { + return err + } + + form.admin.Avatar = form.Avatar + form.admin.Email = form.Email + + if form.Password != "" { + form.admin.SetPassword(form.Password) + } + + return form.app.Dao().SaveAdmin(form.admin) +} diff --git a/forms/admin_upsert_test.go b/forms/admin_upsert_test.go new file mode 100644 index 000000000..c7305283e --- /dev/null +++ b/forms/admin_upsert_test.go @@ -0,0 +1,285 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestNewAdminUpsert(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + admin := &models.Admin{} + admin.Avatar = 3 + admin.Email = "new@example.com" + + form := forms.NewAdminUpsert(app, admin) + + // test defaults + if form.Avatar != admin.Avatar { + t.Errorf("Expected Avatar %d, got %d", admin.Avatar, form.Avatar) + } + if form.Email != admin.Email { + t.Errorf("Expected Email %q, got %q", admin.Email, form.Email) + } +} + +func TestAdminUpsertValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + id string + avatar int + email string + password string + passwordConfirm string + expectedErrors int + }{ + { + "", + -1, + "", + "", + "", + 3, + }, + { + "", + 10, + "invalid", + "12345678", + "87654321", + 4, + }, + { + // existing email + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + 3, + "test2@example.com", + "1234567890", + "1234567890", + 1, + }, + { + // mismatching passwords + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + 3, + "test@example.com", + "1234567890", + "1234567891", + 1, + }, + { + // create without setting password + "", + 9, + "test_create@example.com", + "", + "", + 1, + }, + { + // create with existing email + "", + 9, + "test@example.com", + "1234567890!", + "1234567890!", + 1, + }, + { + // update without setting password + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + 3, + "test_update@example.com", + "", + "", + 0, + }, + { + // create with password + "", + 9, + "test_create@example.com", + "1234567890!", + "1234567890!", + 0, + }, + { + // update with password + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + 4, + "test_update@example.com", + "1234567890", + "1234567890", + 0, + }, + } + + for i, s := range scenarios { + admin := &models.Admin{} + if s.id != "" { + admin, _ = app.Dao().FindAdminById(s.id) + } + + form := forms.NewAdminUpsert(app, admin) + form.Avatar = s.avatar + form.Email = s.email + form.Password = s.password + form.PasswordConfirm = s.passwordConfirm + + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + if len(errs) != s.expectedErrors { + t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs) + } + } +} + +func TestAdminUpsertSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + id string + jsonData string + expectError bool + }{ + { + // create empty + "", + `{}`, + true, + }, + { + // update empty + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + `{}`, + false, + }, + { + // create failure - existing email + "", + `{ + "email": "test@example.com", + "password": "1234567890", + "passwordConfirm": "1234567890" + }`, + true, + }, + { + // create failure - passwords mismatch + "", + `{ + "email": "test_new@example.com", + "password": "1234567890", + "passwordConfirm": "1234567891" + }`, + true, + }, + { + // create success + "", + `{ + "email": "test_new@example.com", + "password": "1234567890", + "passwordConfirm": "1234567890" + }`, + false, + }, + { + // update failure - existing email + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + `{ + "email": "test2@example.com" + }`, + true, + }, + { + // update failure - mismatching passwords + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + `{ + "password": "1234567890", + "passwordConfirm": "1234567891" + }`, + true, + }, + { + // update succcess - new email + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + `{ + "email": "test_update@example.com" + }`, + false, + }, + { + // update succcess - new password + "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", + `{ + "password": "1234567890", + "passwordConfirm": "1234567890" + }`, + false, + }, + } + + for i, s := range scenarios { + isCreate := true + admin := &models.Admin{} + if s.id != "" { + isCreate = false + admin, _ = app.Dao().FindAdminById(s.id) + } + initialTokenKey := admin.TokenKey + + form := forms.NewAdminUpsert(app, admin) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + foundAdmin, _ := app.Dao().FindAdminByEmail(form.Email) + + if !s.expectError && isCreate && foundAdmin == nil { + t.Errorf("(%d) Expected admin to be created, got nil", i) + continue + } + + if s.expectError { + continue // skip persistence check + } + + if foundAdmin.Email != form.Email { + t.Errorf("(%d) Expected email %s, got %s", i, form.Email, foundAdmin.Email) + } + + if foundAdmin.Avatar != form.Avatar { + t.Errorf("(%d) Expected avatar %d, got %d", i, form.Avatar, foundAdmin.Avatar) + } + + if form.Password != "" && initialTokenKey == foundAdmin.TokenKey { + t.Errorf("(%d) Expected token key to be renewed when setting a new password", i) + } + } +} diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go new file mode 100644 index 000000000..6aaf44bee --- /dev/null +++ b/forms/collection_upsert.go @@ -0,0 +1,215 @@ +package forms + +import ( + "regexp" + "strings" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/resolvers" + "github.com/pocketbase/pocketbase/tools/search" +) + +var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`) + +// CollectionUpsert defines a collection upsert (create/update) form. +type CollectionUpsert struct { + app core.App + collection *models.Collection + isCreate bool + + Name string `form:"name" json:"name"` + System bool `form:"system" json:"system"` + Schema schema.Schema `form:"schema" json:"schema"` + ListRule *string `form:"listRule" json:"listRule"` + ViewRule *string `form:"viewRule" json:"viewRule"` + CreateRule *string `form:"createRule" json:"createRule"` + UpdateRule *string `form:"updateRule" json:"updateRule"` + DeleteRule *string `form:"deleteRule" json:"deleteRule"` +} + +// NewCollectionUpsert creates new collection upsert form for the provided Collection model +// (pass an empty Collection model instance (`&models.Collection{}`) for create). +func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert { + form := &CollectionUpsert{ + app: app, + collection: collection, + isCreate: !collection.HasId(), + } + + // load defaults + form.Name = collection.Name + form.System = collection.System + form.ListRule = collection.ListRule + form.ViewRule = collection.ViewRule + form.CreateRule = collection.CreateRule + form.UpdateRule = collection.UpdateRule + form.DeleteRule = collection.DeleteRule + + clone, _ := collection.Schema.Clone() + if clone != nil { + form.Schema = *clone + } else { + form.Schema = schema.Schema{} + } + + return form +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *CollectionUpsert) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.System, + validation.By(form.ensureNoSystemFlagChange), + ), + validation.Field( + &form.Name, + validation.Required, + validation.Length(1, 255), + validation.Match(collectionNameRegex), + validation.By(form.ensureNoSystemNameChange), + validation.By(form.checkUniqueName), + ), + // validates using the type's own validation rules + some collection's specific + validation.Field( + &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)), + validation.Field(&form.CreateRule, validation.By(form.checkRule)), + validation.Field(&form.UpdateRule, validation.By(form.checkRule)), + validation.Field(&form.DeleteRule, validation.By(form.checkRule)), + ) +} + +func (form *CollectionUpsert) checkUniqueName(value any) error { + v, _ := value.(string) + + if !form.app.Dao().IsCollectionNameUnique(v, form.collection.Id) { + 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) { + return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.") + } + + return nil +} + +func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error { + v, _ := value.(string) + + if form.isCreate || !form.collection.System || v == form.collection.Name { + return nil + } + + return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.") +} + +func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error { + v, _ := value.(bool) + + if form.isCreate || v == form.collection.System { + return nil + } + + return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.") +} + +func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error { + v, _ := value.(schema.Schema) + + for _, field := range v.Fields() { + oldField := form.collection.Schema.GetFieldById(field.Id) + + if oldField != nil && oldField.Type != field.Type { + return validation.NewError("validation_field_type_change", "Field type cannot be changed.") + } + } + + return nil +} + +func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error { + v, _ := value.(schema.Schema) + + for _, oldField := range form.collection.Schema.Fields() { + if !oldField.System { + continue + } + + newField := v.GetFieldById(oldField.Id) + + if newField == nil || oldField.String() != newField.String() { + return validation.NewError("validation_system_field_change", "System fields cannot be deleted or changed.") + } + } + + 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) + + if v == nil || *v == "" { + return nil // nothing to check + } + + dummy := &models.Collection{Schema: form.Schema} + r := resolvers.NewRecordFieldResolver(form.app.Dao(), dummy, nil) + + _, err := search.FilterData(*v).BuildExpr(r) + if err != nil { + return validation.NewError("validation_collection_rule", "Invalid filter rule.") + } + + return nil +} + +// 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 { + if err := form.Validate(); err != nil { + return err + } + + // system flag can be set only for create + if form.isCreate { + form.collection.System = form.System + } + + // system collections cannot be renamed + if form.isCreate || !form.collection.System { + form.collection.Name = form.Name + } + + form.collection.Schema = form.Schema + form.collection.ListRule = form.ListRule + form.collection.ViewRule = form.ViewRule + form.collection.CreateRule = form.CreateRule + form.collection.UpdateRule = form.UpdateRule + form.collection.DeleteRule = form.DeleteRule + + return form.app.Dao().SaveCollection(form.collection) +} diff --git a/forms/collection_upsert_test.go b/forms/collection_upsert_test.go new file mode 100644 index 000000000..ec1a9e0dc --- /dev/null +++ b/forms/collection_upsert_test.go @@ -0,0 +1,452 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tests" + "github.com/spf13/cast" +) + +func TestNewCollectionUpsert(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection := &models.Collection{} + collection.Name = "test" + collection.System = true + listRule := "testview" + collection.ListRule = &listRule + viewRule := "test_view" + collection.ViewRule = &viewRule + createRule := "test_create" + collection.CreateRule = &createRule + updateRule := "test_update" + collection.UpdateRule = &updateRule + deleteRule := "test_delete" + collection.DeleteRule = &deleteRule + collection.Schema = schema.NewSchema(&schema.SchemaField{ + Name: "test", + Type: schema.FieldTypeText, + }) + + form := forms.NewCollectionUpsert(app, collection) + + if form.Name != collection.Name { + t.Errorf("Expected Name %q, got %q", collection.Name, form.Name) + } + + if form.System != collection.System { + t.Errorf("Expected System %v, got %v", collection.System, form.System) + } + + if form.ListRule != collection.ListRule { + t.Errorf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule) + } + + if form.ViewRule != collection.ViewRule { + t.Errorf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule) + } + + if form.CreateRule != collection.CreateRule { + t.Errorf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule) + } + + if form.UpdateRule != collection.UpdateRule { + t.Errorf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule) + } + + if form.DeleteRule != collection.DeleteRule { + t.Errorf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule) + } + + // store previous state and modify the collection schema to verify + // that the form.Schema is a deep clone + loadedSchema, _ := collection.Schema.MarshalJSON() + collection.Schema.AddField(&schema.SchemaField{ + Name: "new_field", + Type: schema.FieldTypeBool, + }) + + formSchema, _ := form.Schema.MarshalJSON() + + if string(formSchema) != string(loadedSchema) { + t.Errorf("Expected Schema %v, got %v", string(loadedSchema), string(formSchema)) + } +} + +func TestCollectionUpsertValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + {"{}", []string{"name", "schema"}}, + { + `{ + "name": "test ?!@#$", + "system": true, + "schema": [ + {"name":"","type":"text"} + ], + "listRule": "missing = '123'", + "viewRule": "missing = '123'", + "createRule": "missing = '123'", + "updateRule": "missing = '123'", + "deleteRule": "missing = '123'" + }`, + []string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, + }, + { + `{ + "name": "test", + "system": true, + "schema": [ + {"name":"test","type":"text"} + ], + "listRule": "test='123'", + "viewRule": "test='123'", + "createRule": "test='123'", + "updateRule": "test='123'", + "deleteRule": "test='123'" + }`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewCollectionUpsert(app, &models.Collection{}) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestCollectionUpsertSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + existingName string + jsonData string + expectedErrors []string + }{ + // empty create + {"", "{}", []string{"name", "schema"}}, + // empty update + {"demo", "{}", []string{}}, + // create failure + { + "", + `{ + "name": "test ?!@#$", + "system": true, + "schema": [ + {"name":"","type":"text"} + ], + "listRule": "missing = '123'", + "viewRule": "missing = '123'", + "createRule": "missing = '123'", + "updateRule": "missing = '123'", + "deleteRule": "missing = '123'" + }`, + []string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, + }, + // create failure - existing name + { + "", + `{ + "name": "demo", + "system": true, + "schema": [ + {"name":"test","type":"text"} + ], + "listRule": "test='123'", + "viewRule": "test='123'", + "createRule": "test='123'", + "updateRule": "test='123'", + "deleteRule": "test='123'" + }`, + []string{"name"}, + }, + // create failure - existing internal table + { + "", + `{ + "name": "_users", + "schema": [ + {"name":"test","type":"text"} + ] + }`, + []string{"name"}, + }, + // create failure - name starting with underscore + { + "", + `{ + "name": "_test_new", + "schema": [ + {"name":"test","type":"text"} + ] + }`, + []string{"name"}, + }, + // create failure - duplicated field names (case insensitive) + { + "", + `{ + "name": "test_new", + "schema": [ + {"name":"test","type":"text"}, + {"name":"tESt","type":"text"} + ] + }`, + []string{"schema"}, + }, + // create success + { + "", + `{ + "name": "test_new", + "system": true, + "schema": [ + {"id":"a123456","name":"test1","type":"text"}, + {"id":"b123456","name":"test2","type":"email"} + ], + "listRule": "test1='123'", + "viewRule": "test1='123'", + "createRule": "test1='123'", + "updateRule": "test1='123'", + "deleteRule": "test1='123'" + }`, + []string{}, + }, + // update failure - changing field type + { + "test_new", + `{ + "schema": [ + {"id":"a123456","name":"test1","type":"url"}, + {"id":"b123456","name":"test2","type":"bool"} + ] + }`, + []string{"schema"}, + }, + // update failure - rename fields to existing field names (aka. reusing field names) + { + "test_new", + `{ + "schema": [ + {"id":"a123456","name":"test2","type":"text"}, + {"id":"b123456","name":"test1","type":"email"} + ] + }`, + []string{"schema"}, + }, + // update failure - existing name + { + "demo", + `{"name": "demo2"}`, + []string{"name"}, + }, + // update failure - changing system collection + { + models.ProfileCollectionName, + `{ + "name": "update", + "system": false, + "schema": [ + {"id":"koih1lqx","name":"userId","type":"text"} + ], + "listRule": "userId = '123'", + "viewRule": "userId = '123'", + "createRule": "userId = '123'", + "updateRule": "userId = '123'", + "deleteRule": "userId = '123'" + }`, + []string{"name", "system", "schema"}, + }, + // update failure - all fields + { + "demo", + `{ + "name": "test ?!@#$", + "system": true, + "schema": [ + {"name":"","type":"text"} + ], + "listRule": "missing = '123'", + "viewRule": "missing = '123'", + "createRule": "missing = '123'", + "updateRule": "missing = '123'", + "deleteRule": "missing = '123'" + }`, + []string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, + }, + // update success - update all fields + { + "demo", + `{ + "name": "demo_update", + "schema": [ + {"id":"_2hlxbmp","name":"test","type":"text"} + ], + "listRule": "test='123'", + "viewRule": "test='123'", + "createRule": "test='123'", + "updateRule": "test='123'", + "deleteRule": "test='123'" + }`, + []string{}, + }, + // update failure - rename the schema field of the last updated collection + // (fail due to filters old field references) + { + "demo_update", + `{ + "schema": [ + {"id":"_2hlxbmp","name":"test_renamed","type":"text"} + ] + }`, + []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"}, + }, + // update success - rename the schema field of the last updated collection + // (cleared filter references) + { + "demo_update", + `{ + "schema": [ + {"id":"_2hlxbmp","name":"test_renamed","type":"text"} + ], + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null + }`, + []string{}, + }, + // update success - system collection + { + models.ProfileCollectionName, + `{ + "listRule": "userId='123'", + "viewRule": "userId='123'", + "createRule": "userId='123'", + "updateRule": "userId='123'", + "deleteRule": "userId='123'" + }`, + []string{}, + }, + } + + for i, s := range scenarios { + collection := &models.Collection{} + if s.existingName != "" { + var err error + collection, err = app.Dao().FindCollectionByNameOrId(s.existingName) + if err != nil { + t.Fatal(err) + } + } + + form := forms.NewCollectionUpsert(app, collection) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Submit() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + + if len(s.expectedErrors) > 0 { + continue + } + + collection, _ = app.Dao().FindCollectionByNameOrId(form.Name) + if collection == nil { + t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name) + continue + } + + if form.Name != collection.Name { + t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name) + } + + if form.System != collection.System { + t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System) + } + + if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) { + t.Errorf("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.ListRule) + } + + if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) { + t.Errorf("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.ViewRule) + } + + if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) { + t.Errorf("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.CreateRule) + } + + if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) { + t.Errorf("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.UpdateRule) + } + + if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) { + t.Errorf("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule) + } + + formSchema, _ := form.Schema.MarshalJSON() + collectionSchema, _ := collection.Schema.MarshalJSON() + if string(formSchema) != string(collectionSchema) { + t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema)) + } + } +} diff --git a/forms/realtime_subscribe.go b/forms/realtime_subscribe.go new file mode 100644 index 000000000..8d3cabcf7 --- /dev/null +++ b/forms/realtime_subscribe.go @@ -0,0 +1,23 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +// RealtimeSubscribe defines a RealtimeSubscribe request form. +type RealtimeSubscribe struct { + ClientId string `form:"clientId" json:"clientId"` + Subscriptions []string `form:"subscriptions" json:"subscriptions"` +} + +// NewRealtimeSubscribe creates new RealtimeSubscribe request form. +func NewRealtimeSubscribe() *RealtimeSubscribe { + return &RealtimeSubscribe{} +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *RealtimeSubscribe) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)), + ) +} diff --git a/forms/realtime_subscribe_test.go b/forms/realtime_subscribe_test.go new file mode 100644 index 000000000..d1df830b5 --- /dev/null +++ b/forms/realtime_subscribe_test.go @@ -0,0 +1,31 @@ +package forms_test + +import ( + "strings" + "testing" + + "github.com/pocketbase/pocketbase/forms" +) + +func TestRealtimeSubscribeValidate(t *testing.T) { + scenarios := []struct { + clientId string + expectError bool + }{ + {"", true}, + {strings.Repeat("a", 256), true}, + {"test", false}, + } + + for i, s := range scenarios { + form := forms.NewRealtimeSubscribe() + form.ClientId = s.clientId + + err := form.Validate() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} diff --git a/forms/record_upsert.go b/forms/record_upsert.go new file mode 100644 index 000000000..053123583 --- /dev/null +++ b/forms/record_upsert.go @@ -0,0 +1,368 @@ +package forms + +import ( + "encoding/json" + "errors" + "net/http" + "regexp" + "strconv" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/spf13/cast" +) + +// RecordUpsert defines a Record upsert form. +type RecordUpsert struct { + app core.App + record *models.Record + + isCreate bool + filesToDelete []string // names list + filesToUpload []*rest.UploadedFile + + Data map[string]any `json:"data"` +} + +// NewRecordUpsert creates a new Record upsert form. +// (pass a new Record model instance (`models.NewRecord(...)`) for create). +func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert { + form := &RecordUpsert{ + app: app, + record: record, + isCreate: !record.HasId(), + filesToDelete: []string{}, + filesToUpload: []*rest.UploadedFile{}, + } + + form.Data = map[string]any{} + for _, field := range record.Collection().Schema.Fields() { + form.Data[field.Name] = record.GetDataValue(field.Name) + + } + + return form +} + +func (form *RecordUpsert) getContentType(r *http.Request) string { + t := r.Header.Get("Content-Type") + for i, c := range t { + if c == ' ' || c == ';' { + return t[:i] + } + } + return t +} + +func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) { + switch form.getContentType(r) { + case "application/json": + return form.extractJsonData(r) + case "multipart/form-data": + return form.extractMultipartFormData(r) + default: + return nil, errors.New("Unsupported request Content-Type.") + } +} + +func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) { + result := map[string]any{} + + err := rest.ReadJsonBodyCopy(r, &result) + + return result, err +} + +func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) { + result := map[string]any{} + + // parse form data (if not already) + if err := r.ParseMultipartForm(rest.DefaultMaxMemory); err != nil { + return result, err + } + + arrayValueSupportTypes := schema.ArraybleFieldTypes() + + for key, values := range r.PostForm { + if len(values) == 0 { + result[key] = nil + continue + } + + field := form.record.Collection().Schema.GetFieldByName(key) + if field != nil && list.ExistInSlice(field.Type, arrayValueSupportTypes) { + result[key] = values + } else { + result[key] = values[0] + } + } + + return result, nil +} + +func (form *RecordUpsert) normalizeData() error { + for _, field := range form.record.Collection().Schema.Fields() { + if v, ok := form.Data[field.Name]; ok { + form.Data[field.Name] = field.PrepareValue(v) + } + } + + return nil +} + +// LoadData loads and normalizes json OR multipart/form-data request data. +// +// File upload is supported only via multipart/form-data. +// +// To REPLACE previously uploaded file(s) you can suffix the field name +// with the file index (eg. `myfile.0`) and set the new value. +// For single file upload fields, you can skip the index and directly +// assign the file value to the field name (eg. `myfile`). +// +// To DELETE previously uploaded file(s) you can suffix the field name +// with the file index (eg. `myfile.0`) and set it to null or empty string. +// For single file upload fields, you can skip the index and directly +// reset the field using its field name (eg. `myfile`). +func (form *RecordUpsert) LoadData(r *http.Request) error { + requestData, err := form.extractRequestData(r) + if err != nil { + return err + } + + // extend base data with the extracted one + extendedData := form.record.Data() + rawData, err := json.Marshal(requestData) + if err != nil { + return err + } + if err := json.Unmarshal(rawData, &extendedData); err != nil { + return err + } + + for _, field := range form.record.Collection().Schema.Fields() { + key := field.Name + value, _ := extendedData[key] + value = field.PrepareValue(value) + + if field.Type == schema.FieldTypeFile { + options, _ := field.Options.(*schema.FileOptions) + oldNames := list.ToUniqueStringSlice(form.Data[key]) + + // 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...) + } + form.Data[key] = nil + } + } 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) + continue + } + + // store the id to actually delete the file later + form.filesToDelete = append(form.filesToDelete, name) + } + 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 + } + } else { + form.Data[key] = value + } + } + + return form.normalizeData() +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *RecordUpsert) Validate() error { + dataValidator := validators.NewRecordDataValidator( + form.app.Dao(), + form.record, + form.filesToUpload, + ) + + return dataValidator.Validate(form.Data) +} + +// DrySubmit performs a form submit within a transaction and reverts it. +// For actual record persistence, check the `form.Submit()` method. +// +// This method doesn't handle file uploads/deletes or trigger any app events! +func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error { + if err := form.Validate(); err != nil { + return err + } + + // bulk load form data + if err := form.record.Load(form.Data); err != nil { + return err + } + + return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error { + tx, ok := txDao.DB().(*dbx.Tx) + if !ok { + return errors.New("failed to get transaction db") + } + defer tx.Rollback() + txDao.BeforeCreateFunc = nil + txDao.AfterCreateFunc = nil + txDao.BeforeUpdateFunc = nil + txDao.AfterUpdateFunc = nil + + if err := txDao.SaveRecord(form.record); err != nil { + return err + } + + return callback(txDao) + }) +} + +// Submit validates the form and upserts the form Record model. +func (form *RecordUpsert) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + // bulk load form data + if err := form.record.Load(form.Data); err != nil { + 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 + } + + // 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)... + } + + return nil + }) +} + +func (form *RecordUpsert) processFilesToUpload() error { + if len(form.filesToUpload) == 0 { + return nil // nothing to upload + } + + if !form.record.HasId() { + return errors.New("The record is not persisted yet.") + } + + fs, err := form.app.NewFilesystem() + if err != nil { + return err + } + defer fs.Close() + + 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 { + form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...) + } + } + + if len(form.filesToUpload) > 0 { + return errors.New("Failed to upload all files.") + } + + return nil +} + +func (form *RecordUpsert) processFilesToDelete() error { + if len(form.filesToDelete) == 0 { + return nil // nothing to delete + } + + if !form.record.HasId() { + return errors.New("The record is not persisted yet.") + } + + fs, err := form.app.NewFilesystem() + if err != nil { + return err + } + defer fs.Close() + + 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 { + form.filesToDelete = append(form.filesToDelete[:i], form.filesToDelete[i+1:]...) + } + + // 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.") + } + + return nil +} diff --git a/forms/record_upsert_test.go b/forms/record_upsert_test.go new file mode 100644 index 000000000..a082a9141 --- /dev/null +++ b/forms/record_upsert_test.go @@ -0,0 +1,498 @@ +package forms_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/list" +) + +func TestNewRecordUpsert(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + record := models.NewRecord(collection) + record.SetDataValue("title", "test_value") + + form := forms.NewRecordUpsert(app, record) + + val, _ := form.Data["title"] + if val != "test_value" { + t.Errorf("Expected record data to be load, got %v", form.Data) + } +} + +func TestRecordUpsertLoadDataUnsupported(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) + } + + testData := "title=test123" + + form := forms.NewRecordUpsert(app, record) + req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) + + if err := form.LoadData(req); err == nil { + t.Fatal("Expected LoadData to fail, got nil") + } +} + +func TestRecordUpsertLoadDataJson(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) + } + + testData := map[string]any{ + "title": "test123", + "unknown": "test456", + // file fields unset/delete + "onefile": nil, + "manyfiles.0": "", + "manyfiles.1": "test.png", // should be ignored + "onlyimages": nil, // should be ignored + } + + form := forms.NewRecordUpsert(app, record) + jsonBody, _ := json.Marshal(testData) + req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + loadErr := form.LoadData(req) + if loadErr != nil { + t.Fatal(loadErr) + } + + if v, ok := form.Data["title"]; !ok || v != "test123" { + t.Fatalf("Expect title field to be %q, got %q", "test123", v) + } + + if v, ok := form.Data["unknown"]; ok { + t.Fatalf("Didn't expect unknown field to be set, got %v", v) + } + + onefile, ok := form.Data["onefile"] + if !ok { + t.Fatal("Expect onefile field to be set") + } + if onefile != nil { + t.Fatalf("Expect onefile field to be nil, got %v", onefile) + } + + manyfiles, ok := form.Data["manyfiles"] + if !ok || manyfiles == nil { + t.Fatal("Expect manyfiles field to be set") + } + manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles)) + if manyfilesRemains != 1 { + t.Fatalf("Expect only 1 manyfiles to remain, got %v", manyfiles) + } + + // cannot reset multiple file upload field with just using the field name + onlyimages, ok := form.Data["onlyimages"] + if !ok || onlyimages == nil { + t.Fatal("Expect onlyimages field to be set and not be altered") + } + onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages)) + expectedRemains := 2 // 2 existing + if onlyimagesRemains != expectedRemains { + t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages) + } +} + +func TestRecordUpsertLoadDataMultipart(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) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "test123", + "unknown": "test456", + // file fields unset/delete + "onefile": "", + "manyfiles.0": "", + "manyfiles.1": "test.png", // should be ignored + "onlyimages": "", // should be ignored + }, "onlyimages") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, record) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + loadErr := form.LoadData(req) + if loadErr != nil { + t.Fatal(loadErr) + } + + if v, ok := form.Data["title"]; !ok || v != "test123" { + t.Fatalf("Expect title field to be %q, got %q", "test123", v) + } + + if v, ok := form.Data["unknown"]; ok { + t.Fatalf("Didn't expect unknown field to be set, got %v", v) + } + + onefile, ok := form.Data["onefile"] + if !ok { + t.Fatal("Expect onefile field to be set") + } + if onefile != nil { + t.Fatalf("Expect onefile field to be nil, got %v", onefile) + } + + manyfiles, ok := form.Data["manyfiles"] + if !ok || manyfiles == nil { + t.Fatal("Expect manyfiles field to be set") + } + manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles)) + if manyfilesRemains != 1 { + t.Fatalf("Expect only 1 manyfiles to remain, got %v", manyfiles) + } + + onlyimages, ok := form.Data["onlyimages"] + if !ok || onlyimages == nil { + t.Fatal("Expect onlyimages field to be set and not be altered") + } + onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages)) + expectedRemains := 3 // 2 existing + 1 new upload + if onlyimagesRemains != expectedRemains { + t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages) + } +} + +func TestRecordUpsertValidateFailure(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) + } + + // try with invalid test data to check whether the RecordDataValidator is triggered + formData, mp, err := tests.MockMultipartData(map[string]string{ + "unknown": "test456", // should be ignored + "title": "a", + "onerel": "00000000-84ab-4057-a592-4604a731f78f", + }, "manyfiles", "manyfiles") + if err != nil { + t.Fatal(err) + } + + expectedErrors := []string{"title", "onerel", "manyfiles"} + + form := forms.NewRecordUpsert(app, record) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + result := form.Validate() + + // parse errors + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Fatalf("Failed to parse errors %v", result) + } + + // check errors + if len(errs) > len(expectedErrors) { + t.Fatalf("Expected error keys %v, got %v", expectedErrors, errs) + } + for _, k := range expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("Missing expected error key %q in %v", k, errs) + } + } +} + +func TestRecordUpsertValidateSuccess(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) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "unknown": "test456", // should be ignored + "title": "abc", + "onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", + }, "manyfiles", "onefile") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, record) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + result := form.Validate() + if result != nil { + t.Fatal(result) + } +} + +func TestRecordUpsertDrySubmitFailure(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo4") + recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2") + if err != nil { + t.Fatal(err) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "a", + "onerel": "00000000-84ab-4057-a592-4604a731f78f", + }) + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, recordBefore) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + callbackCalls := 0 + + // ensure that validate is triggered + // --- + result := form.DrySubmit(func(txDao *daos.Dao) error { + callbackCalls++ + return nil + }) + if result == nil { + t.Fatal("Expected error, got nil") + } + if callbackCalls != 0 { + t.Fatalf("Expected callbackCalls to be 0, got %d", callbackCalls) + } + + // ensure that the record changes weren't persisted + // --- + recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id) + if err != nil { + t.Fatal(err) + } + + if recordAfter.GetStringDataValue("title") == "a" { + t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a") + } + + if recordAfter.GetStringDataValue("onerel") == "00000000-84ab-4057-a592-4604a731f78f" { + t.Fatalf("Expected record.onerel to be %s, got %s", recordBefore.GetStringDataValue("onerel"), recordAfter.GetStringDataValue("onerel")) + } +} + +func TestRecordUpsertDrySubmitSuccess(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo4") + recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2") + if err != nil { + t.Fatal(err) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "dry_test", + "onefile": "", + }, "manyfiles") + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, recordBefore) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + callbackCalls := 0 + + result := form.DrySubmit(func(txDao *daos.Dao) error { + callbackCalls++ + return nil + }) + if result != nil { + t.Fatalf("Expected nil, got error %v", result) + } + + // ensure callback was called + if callbackCalls != 1 { + t.Fatalf("Expected callbackCalls to be 1, got %d", callbackCalls) + } + + // ensure that the record changes weren't persisted + // --- + recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id) + if err != nil { + t.Fatal(err) + } + + if recordAfter.GetStringDataValue("title") == "dry_test" { + t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "dry_test") + } + if recordAfter.GetStringDataValue("onefile") == "" { + t.Fatal("Expected record.onefile to be set, got empty string") + } + + // file wasn't removed + if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) { + t.Fatal("onefile file should not have been deleted") + } +} + +func TestRecordUpsertSubmitFailure(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo4") + recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2") + if err != nil { + t.Fatal(err) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "a", + "onefile": "", + }) + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, recordBefore) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + // ensure that validate is triggered + // --- + result := form.Submit() + if result == nil { + t.Fatal("Expected error, got nil") + } + + // ensure that the record changes weren't persisted + // --- + recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id) + if err != nil { + t.Fatal(err) + } + + if recordAfter.GetStringDataValue("title") == "a" { + t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a") + } + + if recordAfter.GetStringDataValue("onefile") == "" { + t.Fatal("Expected record.onefile to be set, got empty string") + } + + // file wasn't removed + if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) { + t.Fatal("onefile file should not have been deleted") + } +} + +func TestRecordUpsertSubmitSuccess(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo4") + recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2") + if err != nil { + t.Fatal(err) + } + + formData, mp, err := tests.MockMultipartData(map[string]string{ + "title": "test_save", + "onefile": "", + }, "manyfiles.1", "manyfiles") // replace + new file + if err != nil { + t.Fatal(err) + } + + form := forms.NewRecordUpsert(app, recordBefore) + req := httptest.NewRequest(http.MethodGet, "/", formData) + req.Header.Set(echo.HeaderContentType, mp.FormDataContentType()) + form.LoadData(req) + + result := form.Submit() + if result != nil { + t.Fatalf("Expected nil, got error %v", result) + } + + // ensure that the record changes were persisted + // --- + recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id) + if err != nil { + t.Fatal(err) + } + + if recordAfter.GetStringDataValue("title") != "test_save" { + t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "test_save") + } + + if hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) { + t.Fatal("Expected record.onefile to be deleted") + } + + manyfiles := (recordAfter.GetStringSliceDataValue("manyfiles")) + if len(manyfiles) != 3 { + t.Fatalf("Expected 3 manyfiles, got %d (%v)", len(manyfiles), manyfiles) + } + for _, f := range manyfiles { + if !hasRecordFile(app, recordAfter, f) { + t.Fatalf("Expected file %q to exist", f) + } + } +} + +func hasRecordFile(app core.App, record *models.Record, filename string) bool { + fs, _ := app.NewFilesystem() + defer fs.Close() + + fileKey := filepath.Join( + record.Collection().Id, + record.Id, + filename, + ) + + exists, _ := fs.Exists(fileKey) + + return exists +} diff --git a/forms/settings_upsert.go b/forms/settings_upsert.go new file mode 100644 index 000000000..22dde5ca8 --- /dev/null +++ b/forms/settings_upsert.go @@ -0,0 +1,59 @@ +package forms + +import ( + "os" + "time" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" +) + +// SettingsUpsert defines app settings upsert form. +type SettingsUpsert struct { + *core.Settings + + app core.App +} + +// NewSettingsUpsert creates new settings upsert form from the provided app. +func NewSettingsUpsert(app core.App) *SettingsUpsert { + form := &SettingsUpsert{app: app} + + // load the application settings into the form + form.Settings, _ = app.Settings().Clone() + + return form +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *SettingsUpsert) Validate() error { + return form.Settings.Validate() +} + +// 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 { + 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 + } + + // 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) +} diff --git a/forms/settings_upsert_test.go b/forms/settings_upsert_test.go new file mode 100644 index 000000000..01c838cd1 --- /dev/null +++ b/forms/settings_upsert_test.go @@ -0,0 +1,130 @@ +package forms_test + +import ( + "encoding/json" + "os" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestNewSettingsUpsert(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + app.Settings().Meta.AppName = "name_update" + + form := forms.NewSettingsUpsert(app) + + formSettings, _ := json.Marshal(form.Settings) + appSettings, _ := json.Marshal(app.Settings()) + + if string(formSettings) != string(appSettings) { + t.Errorf("Expected settings \n%s, got \n%s", string(appSettings), string(formSettings)) + } +} + +func TestSettingsUpsertValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + form := forms.NewSettingsUpsert(app) + + // check if settings validations are triggered + // (there are already individual tests for each setting) + form.Meta.AppName = "" + form.Logs.MaxDays = -10 + + // parse errors + err := form.Validate() + jsonResult, _ := json.Marshal(err) + + expected := `{"logs":{"maxDays":"must be no less than 0"},"meta":{"appName":"cannot be blank"}}` + + if string(jsonResult) != expected { + t.Errorf("Expected %v, got %v", expected, string(jsonResult)) + } +} + +func TestSettingsUpsertSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + encryption bool + expectedErrors []string + }{ + // empty (plain) + {"{}", false, nil}, + // empty (encrypt) + {"{}", true, nil}, + // failure - invalid data + { + `{"emailAuth": {"minPasswordLength": 1}, "logs": {"maxDays": -1}}`, + false, + []string{"emailAuth", "logs"}, + }, + // success - valid data (plain) + { + `{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`, + false, + nil, + }, + // success - valid data (encrypt) + { + `{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`, + true, + nil, + }, + } + + for i, s := range scenarios { + if s.encryption { + os.Setenv(app.EncryptionEnv(), security.RandomString(32)) + } else { + os.Unsetenv(app.EncryptionEnv()) + } + + form := forms.NewSettingsUpsert(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Submit() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + + if len(s.expectedErrors) > 0 { + continue + } + + formSettings, _ := json.Marshal(form.Settings) + appSettings, _ := json.Marshal(app.Settings()) + + if string(formSettings) != string(appSettings) { + t.Errorf("Expected app settings \n%s, got \n%s", string(appSettings), string(formSettings)) + } + } +} diff --git a/forms/user_email_change_confirm.go b/forms/user_email_change_confirm.go new file mode 100644 index 000000000..cc5a44b63 --- /dev/null +++ b/forms/user_email_change_confirm.go @@ -0,0 +1,113 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/security" +) + +// UserEmailChangeConfirm defines a user email change confirmation form. +type UserEmailChangeConfirm struct { + app core.App + + Token string `form:"token" json:"token"` + Password string `form:"password" json:"password"` +} + +// NewUserEmailChangeConfirm creates new user email change confirmation form. +func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm { + return &UserEmailChangeConfirm{ + app: app, + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserEmailChangeConfirm) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.Token, + validation.Required, + validation.By(form.checkToken), + ), + validation.Field( + &form.Password, + validation.Required, + validation.Length(1, 100), + validation.By(form.checkPassword), + ), + ) +} + +func (form *UserEmailChangeConfirm) checkToken(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + _, _, err := form.parseToken(v) + + return err +} + +func (form *UserEmailChangeConfirm) checkPassword(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + user, _, _ := form.parseToken(form.Token) + if user == nil || !user.ValidatePassword(v) { + return validation.NewError("validation_invalid_password", "Missing or invalid user password.") + } + + return nil +} + +func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, string, error) { + // check token payload + claims, _ := security.ParseUnverifiedJWT(token) + newEmail, _ := claims["newEmail"].(string) + if newEmail == "" { + return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.") + } + + // ensure that there aren't other users with the new email + if !form.app.Dao().IsUserEmailUnique(newEmail, "") { + return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail) + } + + // verify that the token is not expired and its signiture is valid + user, err := form.app.Dao().FindUserByToken( + token, + form.app.Settings().UserEmailChangeToken.Secret, + ) + if err != nil || user == nil { + return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.") + } + + return user, newEmail, nil +} + +// Submit validates and submits the user email change confirmation form. +// On success returns the updated user model associated to `form.Token`. +func (form *UserEmailChangeConfirm) Submit() (*models.User, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + user, newEmail, err := form.parseToken(form.Token) + if err != nil { + return nil, err + } + + user.Email = newEmail + user.Verified = true + user.RefreshTokenKey() // invalidate old tokens + + if err := form.app.Dao().SaveUser(user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/forms/user_email_change_confirm_test.go b/forms/user_email_change_confirm_test.go new file mode 100644 index 000000000..cc5d75027 --- /dev/null +++ b/forms/user_email_change_confirm_test.go @@ -0,0 +1,121 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty payload + {"{}", []string{"token", "password"}}, + // empty data + { + `{"token": "", "password": ""}`, + []string{"token", "password"}, + }, + // invalid token payload + { + `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTE2NDYxfQ.VjT3wc3IES--1Vye-1KRuk8RpO5mfdhVp2aKGbNluZ0", + "password": "123456" + }`, + []string{"token", "password"}, + }, + // expired token + { + `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.oPxbpJjcBpdZVBFbIW35FEXTCMkzJ7-RmQdHrz7zP3s", + "password": "123456" + }`, + []string{"token", "password"}, + }, + // existing new email + { + `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.RwHRZma5YpCwxHdj3y2obeBNy_GQrG6lT9CQHIUz6Ys", + "password": "123456" + }`, + []string{"token", "password"}, + }, + // wrong confirmation password + { + `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0", + "password": "1234" + }`, + []string{"password"}, + }, + // valid data + { + `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0", + "password": "123456" + }`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserEmailChangeConfirm(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + user, err := form.Submit() + + // parse errors + errs, ok := err.(validation.Errors) + if !ok && err != nil { + t.Errorf("(%d) Failed to parse errors %v", i, err) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + + if len(s.expectedErrors) > 0 { + continue + } + + claims, _ := security.ParseUnverifiedJWT(form.Token) + newEmail, _ := claims["newEmail"].(string) + + // check whether the user was updated + // --- + if user.Email != newEmail { + t.Errorf("(%d) Expected user email %q, got %q", i, newEmail, user.Email) + } + + if !user.Verified { + t.Errorf("(%d) Expected user to be verified, got false", i) + } + + // shouldn't validate second time due to refreshed user token + if err := form.Validate(); err == nil { + t.Errorf("(%d) Expected error, got nil", i) + } + } +} diff --git a/forms/user_email_change_request.go b/forms/user_email_change_request.go new file mode 100644 index 000000000..3a02043fa --- /dev/null +++ b/forms/user_email_change_request.go @@ -0,0 +1,57 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/models" +) + +// UserEmailChangeConfirm defines a user email change request form. +type UserEmailChangeRequest struct { + app core.App + user *models.User + + NewEmail string `form:"newEmail" json:"newEmail"` +} + +// NewUserEmailChangeRequest creates a new user email change request form. +func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest { + return &UserEmailChangeRequest{ + app: app, + user: user, + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserEmailChangeRequest) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.NewEmail, + validation.Required, + validation.Length(1, 255), + is.Email, + validation.By(form.checkUniqueEmail), + ), + ) +} + +func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error { + v, _ := value.(string) + + if !form.app.Dao().IsUserEmailUnique(v, "") { + return validation.NewError("validation_user_email_exists", "User email already exists.") + } + + return nil +} + +// Submit validates and sends the change email request. +func (form *UserEmailChangeRequest) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + return mails.SendUserChangeEmail(form.app, form.user, form.NewEmail) +} diff --git a/forms/user_email_change_request_test.go b/forms/user_email_change_request_test.go new file mode 100644 index 000000000..e81df9e06 --- /dev/null +++ b/forms/user_email_change_request_test.go @@ -0,0 +1,87 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" +) + +func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + user, err := testApp.Dao().FindUserByEmail("test@example.com") + if err != nil { + t.Fatal(err) + } + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty payload + {"{}", []string{"newEmail"}}, + // empty data + { + `{"newEmail": ""}`, + []string{"newEmail"}, + }, + // invalid email + { + `{"newEmail": "invalid"}`, + []string{"newEmail"}, + }, + // existing email token + { + `{"newEmail": "test@example.com"}`, + []string{"newEmail"}, + }, + // valid new email + { + `{"newEmail": "test_new@example.com"}`, + []string{}, + }, + } + + for i, s := range scenarios { + testApp.TestMailer.TotalSend = 0 // reset + form := forms.NewUserEmailChangeRequest(testApp, user) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Submit() + + // parse errors + errs, ok := err.(validation.Errors) + if !ok && err != nil { + t.Errorf("(%d) Failed to parse errors %v", i, err) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + + expectedMails := 1 + if len(s.expectedErrors) > 0 { + expectedMails = 0 + } + if testApp.TestMailer.TotalSend != expectedMails { + t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) + } + } +} diff --git a/forms/user_email_login.go b/forms/user_email_login.go new file mode 100644 index 000000000..49e49d154 --- /dev/null +++ b/forms/user_email_login.go @@ -0,0 +1,52 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" +) + +// UserEmailLogin defines a user email/pass login form. +type UserEmailLogin struct { + app core.App + + Email string `form:"email" json:"email"` + Password string `form:"password" json:"password"` +} + +// NewUserEmailLogin creates a new user email/pass login form. +func NewUserEmailLogin(app core.App) *UserEmailLogin { + form := &UserEmailLogin{ + app: app, + } + + return form +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserEmailLogin) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.Email), + validation.Field(&form.Password, validation.Required, validation.Length(1, 255)), + ) +} + +// Submit validates and submits the form. +// On success returns the authorized user model. +func (form *UserEmailLogin) Submit() (*models.User, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + user, err := form.app.Dao().FindUserByEmail(form.Email) + if err != nil { + return nil, err + } + + if !user.ValidatePassword(form.Password) { + return nil, validation.NewError("invalid_login", "Invalid login credentials.") + } + + return user, nil +} diff --git a/forms/user_email_login_test.go b/forms/user_email_login_test.go new file mode 100644 index 000000000..2d4b867d2 --- /dev/null +++ b/forms/user_email_login_test.go @@ -0,0 +1,106 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" +) + +func TestUserEmailLoginValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty payload + {"{}", []string{"email", "password"}}, + // empty data + { + `{"email": "","password": ""}`, + []string{"email", "password"}, + }, + // invalid email + { + `{"email": "invalid","password": "123"}`, + []string{"email"}, + }, + // valid email + { + `{"email": "test@example.com","password": "123"}`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserEmailLogin(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Validate() + + // parse errors + errs, ok := err.(validation.Errors) + if !ok && err != nil { + t.Errorf("(%d) Failed to parse errors %v", i, err) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserEmailLoginSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + email string + password string + expectError bool + }{ + // invalid email + {"invalid", "123456", true}, + // missing user + {"missing@example.com", "123456", true}, + // invalid password + {"test@example.com", "123", true}, + // valid email and password + {"test@example.com", "123456", false}, + } + + for i, s := range scenarios { + form := forms.NewUserEmailLogin(app) + form.Email = s.email + form.Password = s.password + + user, err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + continue + } + + if !s.expectError && user.Email != s.email { + t.Errorf("(%d) Expected user with email %q, got %q", i, s.email, user.Email) + } + } +} diff --git a/forms/user_oauth2_login.go b/forms/user_oauth2_login.go new file mode 100644 index 000000000..ab8f86c4c --- /dev/null +++ b/forms/user_oauth2_login.go @@ -0,0 +1,133 @@ +package forms + +import ( + "errors" + "fmt" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/auth" + "github.com/pocketbase/pocketbase/tools/security" + "golang.org/x/oauth2" +) + +// UserOauth2Login defines a user Oauth2 login form. +type UserOauth2Login struct { + app core.App + + // The name of the OAuth2 client provider (eg. "google") + Provider string `form:"provider" json:"provider"` + + // The authorization code returned from the initial request. + Code string `form:"code" json:"code"` + + // The code verifier sent with the initial request as part of the code_challenge. + CodeVerifier string `form:"codeVerifier" json:"codeVerifier"` + + // The redirect url sent with the initial request. + RedirectUrl string `form:"redirectUrl" json:"redirectUrl"` +} + +// NewUserOauth2Login creates a new user Oauth2 login form. +func NewUserOauth2Login(app core.App) *UserOauth2Login { + return &UserOauth2Login{app: app} +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserOauth2Login) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)), + validation.Field(&form.Code, validation.Required), + validation.Field(&form.CodeVerifier, validation.Required), + validation.Field(&form.RedirectUrl, validation.Required, is.URL), + ) +} + +func (form *UserOauth2Login) checkProviderName(value any) error { + name, _ := value.(string) + + config, ok := form.app.Settings().NamedAuthProviderConfigs()[name] + if !ok || !config.Enabled { + return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name)) + } + + return nil +} + +// Submit validates and submits the form. +// On success returns the authorized user model and the fetched provider's data. +func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) { + if err := form.Validate(); err != nil { + return nil, nil, err + } + + provider, err := auth.NewProviderByName(form.Provider) + if err != nil { + return nil, nil, err + } + + config, _ := form.app.Settings().NamedAuthProviderConfigs()[form.Provider] + config.SetupProvider(provider) + + provider.SetRedirectUrl(form.RedirectUrl) + + // fetch token + token, err := provider.FetchToken( + form.Code, + oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier), + ) + if err != nil { + return nil, nil, err + } + + // fetch auth user + authData, err := provider.FetchAuthUser(token) + if err != nil { + return nil, nil, err + } + + // login/register the auth user + user, _ := form.app.Dao().FindUserByEmail(authData.Email) + if user != nil { + // update the existing user's verified state + if !user.Verified { + user.Verified = true + if err := form.app.Dao().SaveUser(user); err != nil { + 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.") + } + + // 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 + + event := &core.UserOauth2RegisterEvent{ + User: user, + AuthData: authData, + } + + if err := form.app.OnUserBeforeOauth2Register().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 +} diff --git a/forms/user_oauth2_login_test.go b/forms/user_oauth2_login_test.go new file mode 100644 index 000000000..13f3b0d05 --- /dev/null +++ b/forms/user_oauth2_login_test.go @@ -0,0 +1,75 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" +) + +func TestUserOauth2LoginValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty payload + {"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}}, + // empty data + { + `{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`, + []string{"provider", "code", "codeVerifier", "redirectUrl"}, + }, + // missing provider + { + `{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, + []string{"provider"}, + }, + // disabled provider + { + `{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, + []string{"provider"}, + }, + // enabled provider + { + `{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserOauth2Login(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Validate() + + // parse errors + errs, ok := err.(validation.Errors) + if !ok && err != nil { + t.Errorf("(%d) Failed to parse errors %v", i, err) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +// @todo consider mocking a Oauth2 provider to test Submit diff --git a/forms/user_password_reset_confirm.go b/forms/user_password_reset_confirm.go new file mode 100644 index 000000000..e97ce29e1 --- /dev/null +++ b/forms/user_password_reset_confirm.go @@ -0,0 +1,78 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" +) + +// UserPasswordResetConfirm defines a user password reset confirmation form. +type UserPasswordResetConfirm struct { + app core.App + + Token string `form:"token" json:"token"` + Password string `form:"password" json:"password"` + PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` +} + +// NewUserPasswordResetConfirm creates new user password reset confirmation form. +func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm { + return &UserPasswordResetConfirm{ + app: app, + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserPasswordResetConfirm) Validate() error { + minPasswordLength := form.app.Settings().EmailAuth.MinPasswordLength + + return validation.ValidateStruct(form, + validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), + validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)), + validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))), + ) +} + +func (form *UserPasswordResetConfirm) checkToken(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + user, err := form.app.Dao().FindUserByToken( + v, + form.app.Settings().UserPasswordResetToken.Secret, + ) + if err != nil || user == nil { + return validation.NewError("validation_invalid_token", "Invalid or expired token.") + } + + return nil +} + +// Submit validates and submits the form. +// On success returns the updated user model associated to `form.Token`. +func (form *UserPasswordResetConfirm) Submit() (*models.User, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + user, err := form.app.Dao().FindUserByToken( + form.Token, + form.app.Settings().UserPasswordResetToken.Secret, + ) + if err != nil { + return nil, err + } + + if err := user.SetPassword(form.Password); err != nil { + return nil, err + } + + if err := form.app.Dao().SaveUser(user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/forms/user_password_reset_confirm_test.go b/forms/user_password_reset_confirm_test.go new file mode 100644 index 000000000..0300e6ed0 --- /dev/null +++ b/forms/user_password_reset_confirm_test.go @@ -0,0 +1,165 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestUserPasswordResetConfirmValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty data + { + `{}`, + []string{"token", "password", "passwordConfirm"}, + }, + // empty fields + { + `{"token":"","password":"","passwordConfirm":""}`, + []string{"token", "password", "passwordConfirm"}, + }, + // invalid password length + { + `{"token":"invalid","password":"1234","passwordConfirm":"1234"}`, + []string{"token", "password"}, + }, + // mismatched passwords + { + `{"token":"invalid","password":"12345678","passwordConfirm":"87654321"}`, + []string{"token", "passwordConfirm"}, + }, + // invalid JWT token + { + `{"token":"invalid","password":"12345678","passwordConfirm":"12345678"}`, + []string{"token"}, + }, + // expired token + { + `{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw", + "password":"12345678", + "passwordConfirm":"12345678" + }`, + []string{"token"}, + }, + // valid data + { + `{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4", + "password":"12345678", + "passwordConfirm":"12345678" + }`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserPasswordResetConfirm(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserPasswordResetConfirmSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectError bool + }{ + // empty data (Validate call check) + { + `{}`, + true, + }, + // expired token + { + `{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw", + "password":"12345678", + "passwordConfirm":"12345678" + }`, + true, + }, + // valid data + { + `{ + "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4", + "password":"12345678", + "passwordConfirm":"12345678" + }`, + false, + }, + } + + for i, s := range scenarios { + form := forms.NewUserPasswordResetConfirm(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + user, err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + if s.expectError { + continue + } + + claims, _ := security.ParseUnverifiedJWT(form.Token) + tokenUserId, _ := claims["id"] + + if user.Id != tokenUserId { + t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user) + } + + if !user.LastResetSentAt.IsZero() { + t.Errorf("(%d) Expected user.LastResetSentAt to be empty, got %v", i, user.LastResetSentAt) + } + + if !user.ValidatePassword(form.Password) { + t.Errorf("(%d) Expected the user password to have been updated to %q", i, form.Password) + } + } +} diff --git a/forms/user_password_reset_request.go b/forms/user_password_reset_request.go new file mode 100644 index 000000000..f879e1a63 --- /dev/null +++ b/forms/user_password_reset_request.go @@ -0,0 +1,70 @@ +package forms + +import ( + "errors" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/tools/types" +) + +// UserPasswordResetRequest defines a user password reset request form. +type UserPasswordResetRequest struct { + app core.App + resendThreshold float64 + + Email string `form:"email" json:"email"` +} + +// NewUserPasswordResetRequest creates new user password reset request form. +func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest { + return &UserPasswordResetRequest{ + app: app, + resendThreshold: 120, // 2 min + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +// +// This method doesn't checks whether user with `form.Email` exists (this is done on Submit). +func (form *UserPasswordResetRequest) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.Email, + validation.Required, + validation.Length(1, 255), + is.Email, + ), + ) +} + +// Submit validates and submits the form. +// On success sends a password reset email to the `form.Email` user. +func (form *UserPasswordResetRequest) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + user, err := form.app.Dao().FindUserByEmail(form.Email) + if err != nil { + return err + } + + now := time.Now().UTC() + lastResetSentAt := user.LastResetSentAt.Time() + if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold { + return errors.New("You've already requested a password reset.") + } + + if err := mails.SendUserPasswordReset(form.app, user); err != nil { + return err + } + + // update last sent timestamp + user.LastResetSentAt = types.NowDateTime() + + return form.app.Dao().SaveUser(user) +} diff --git a/forms/user_password_reset_request_test.go b/forms/user_password_reset_request_test.go new file mode 100644 index 000000000..cc4224bd1 --- /dev/null +++ b/forms/user_password_reset_request_test.go @@ -0,0 +1,153 @@ +package forms_test + +import ( + "encoding/json" + "testing" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestUserPasswordResetRequestValidate(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty data + { + `{}`, + []string{"email"}, + }, + // empty fields + { + `{"email":""}`, + []string{"email"}, + }, + // invalid email format + { + `{"email":"invalid"}`, + []string{"email"}, + }, + // valid email + { + `{"email":"new@example.com"}`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserPasswordResetRequest(testApp) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserPasswordResetRequestSubmit(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + scenarios := []struct { + jsonData string + expectError bool + }{ + // empty field (Validate call check) + { + `{"email":""}`, + true, + }, + // invalid email field (Validate call check) + { + `{"email":"invalid"}`, + true, + }, + // nonexisting user + { + `{"email":"missing@example.com"}`, + true, + }, + // existing user + { + `{"email":"test@example.com"}`, + false, + }, + // existing user - reached send threshod + { + `{"email":"test@example.com"}`, + true, + }, + } + + now := types.NowDateTime() + time.Sleep(1 * time.Millisecond) + + for i, s := range scenarios { + testApp.TestMailer.TotalSend = 0 // reset + form := forms.NewUserPasswordResetRequest(testApp) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + expectedMails := 1 + if s.expectError { + expectedMails = 0 + } + if testApp.TestMailer.TotalSend != expectedMails { + t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) + } + + if s.expectError { + continue + } + + // check whether LastResetSentAt was updated + user, err := testApp.Dao().FindUserByEmail(form.Email) + if err != nil { + t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email) + continue + } + + if user.LastResetSentAt.Time().Sub(now.Time()) < 0 { + t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt) + } + } +} diff --git a/forms/user_upsert.go b/forms/user_upsert.go new file mode 100644 index 000000000..c78a3e101 --- /dev/null +++ b/forms/user_upsert.go @@ -0,0 +1,118 @@ +package forms + +import ( + "strings" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/types" +) + +// UserUpsert defines a user upsert (create/update) form. +type UserUpsert struct { + app core.App + user *models.User + isCreate bool + + Email string `form:"email" json:"email"` + Password string `form:"password" json:"password"` + PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"` +} + +// NewUserUpsert creates new upsert form for the provided user model +// (pass an empty user model instance (`&models.User{}`) for create). +func NewUserUpsert(app core.App, user *models.User) *UserUpsert { + form := &UserUpsert{ + app: app, + user: user, + isCreate: !user.HasId(), + } + + // load defaults + form.Email = user.Email + + return form +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserUpsert) Validate() error { + config := form.app.Settings() + + return validation.ValidateStruct(form, + validation.Field( + &form.Email, + validation.Required, + validation.Length(1, 255), + is.Email, + validation.By(form.checkEmailDomain), + validation.By(form.checkUniqueEmail), + ), + validation.Field( + &form.Password, + validation.When(form.isCreate, validation.Required), + validation.Length(config.EmailAuth.MinPasswordLength, 100), + ), + validation.Field( + &form.PasswordConfirm, + validation.When(form.isCreate || form.Password != "", validation.Required), + validation.By(validators.Compare(form.Password)), + ), + ) +} + +func (form *UserUpsert) checkUniqueEmail(value any) error { + v, _ := value.(string) + + if v == "" || form.app.Dao().IsUserEmailUnique(v, form.user.Id) { + return nil + } + + return validation.NewError("validation_user_email_exists", "User email already exists.") +} + +func (form *UserUpsert) checkEmailDomain(value any) error { + val, _ := value.(string) + if val == "" { + return nil // nothing to check + } + + domain := val[strings.LastIndex(val, "@")+1:] + only := form.app.Settings().EmailAuth.OnlyDomains + except := form.app.Settings().EmailAuth.ExceptDomains + + // only domains check + if len(only) > 0 && !list.ExistInSlice(domain, only) { + return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.") + } + + // except domains check + if len(except) > 0 && list.ExistInSlice(domain, except) { + return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.") + } + + return nil +} + +// Submit validates the form and upserts the form user model. +func (form *UserUpsert) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + if form.Password != "" { + form.user.SetPassword(form.Password) + } + + if !form.isCreate && form.Email != form.user.Email { + form.user.Verified = false + form.user.LastVerificationSentAt = types.DateTime{} // reset + } + + form.user.Email = form.Email + + return form.app.Dao().SaveUser(form.user) +} diff --git a/forms/user_upsert_test.go b/forms/user_upsert_test.go new file mode 100644 index 000000000..4612c180f --- /dev/null +++ b/forms/user_upsert_test.go @@ -0,0 +1,242 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestNewUserUpsert(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + user := &models.User{} + user.Email = "new@example.com" + + form := forms.NewUserUpsert(app, user) + + // check defaults loading + if form.Email != user.Email { + t.Fatalf("Expected email %q, got %q", user.Email, form.Email) + } +} + +func TestUserUpsertValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // mock app constraints + app.Settings().EmailAuth.MinPasswordLength = 5 + app.Settings().EmailAuth.ExceptDomains = []string{"test.com"} + app.Settings().EmailAuth.OnlyDomains = []string{"example.com", "test.com"} + + scenarios := []struct { + id string + jsonData string + expectedErrors []string + }{ + // empty data - create + { + "", + `{}`, + []string{"email", "password", "passwordConfirm"}, + }, + // empty data - update + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{}`, + []string{}, + }, + // invalid email address + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"invalid"}`, + []string{"email"}, + }, + // unique email constraint check (same email, aka. no changes) + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"test@example.com"}`, + []string{}, + }, + // unique email constraint check (existing email) + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"test2@something.com"}`, + []string{"email"}, + }, + // unique email constraint check (new email) + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"new@example.com"}`, + []string{}, + }, + // EmailAuth.OnlyDomains constraints check + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"test@something.com"}`, + []string{"email"}, + }, + // EmailAuth.ExceptDomains constraints check + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"test@test.com"}`, + []string{"email"}, + }, + // password length constraint check + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"password":"1234", "passwordConfirm": "1234"}`, + []string{"password"}, + }, + // passwords mismatch + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"password":"12345", "passwordConfirm": "54321"}`, + []string{"passwordConfirm"}, + }, + // valid data - all fields + { + "", + `{"email":"new@example.com","password":"12345","passwordConfirm":"12345"}`, + []string{}, + }, + } + + for i, s := range scenarios { + user := &models.User{} + if s.id != "" { + user, _ = app.Dao().FindUserById(s.id) + } + + form := forms.NewUserUpsert(app, user) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserUpsertSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + id string + jsonData string + expectError bool + }{ + // empty fields - create (Validate call check) + { + "", + `{}`, + true, + }, + // empty fields - update (Validate call check) + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{}`, + false, + }, + // updating with existing user email + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"test2@example.com"}`, + true, + }, + // updating with nonexisting user email + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"email":"update_new@example.com"}`, + false, + }, + // changing password + { + "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", + `{"password":"123456789","passwordConfirm":"123456789"}`, + false, + }, + // creating user (existing email) + { + "", + `{"email":"test3@example.com","password":"123456789","passwordConfirm":"123456789"}`, + true, + }, + // creating user (new email) + { + "", + `{"email":"create_new@example.com","password":"123456789","passwordConfirm":"123456789"}`, + false, + }, + } + + for i, s := range scenarios { + user := &models.User{} + originalUser := &models.User{} + if s.id != "" { + user, _ = app.Dao().FindUserById(s.id) + originalUser, _ = app.Dao().FindUserById(s.id) + } + + form := forms.NewUserUpsert(app, user) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + if s.expectError { + continue + } + + if user.Email != form.Email { + t.Errorf("(%d) Expected email %q, got %q", i, form.Email, user.Email) + } + + // on email change Verified should reset + if user.Email != originalUser.Email && user.Verified { + t.Errorf("(%d) Expected Verified to be false, got true", i) + } + + if form.Password != "" && !user.ValidatePassword(form.Password) { + t.Errorf("(%d) Expected password to be updated to %q", i, form.Password) + } + if form.Password != "" && originalUser.TokenKey == user.TokenKey { + t.Errorf("(%d) Expected TokenKey to change, got %q", i, user.TokenKey) + } + } +} diff --git a/forms/user_verification_confirm.go b/forms/user_verification_confirm.go new file mode 100644 index 000000000..4bcd09350 --- /dev/null +++ b/forms/user_verification_confirm.go @@ -0,0 +1,73 @@ +package forms + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" +) + +// UserVerificationConfirm defines a user email confirmation form. +type UserVerificationConfirm struct { + app core.App + + Token string `form:"token" json:"token"` +} + +// NewUserVerificationConfirm creates a new user email confirmation form. +func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm { + return &UserVerificationConfirm{ + app: app, + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +func (form *UserVerificationConfirm) Validate() error { + return validation.ValidateStruct(form, + validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)), + ) +} + +func (form *UserVerificationConfirm) checkToken(value any) error { + v, _ := value.(string) + if v == "" { + return nil // nothing to check + } + + user, err := form.app.Dao().FindUserByToken( + v, + form.app.Settings().UserVerificationToken.Secret, + ) + if err != nil || user == nil { + return validation.NewError("validation_invalid_token", "Invalid or expired token.") + } + + return nil +} + +// Submit validates and submits the form. +// On success returns the verified user model associated to `form.Token`. +func (form *UserVerificationConfirm) Submit() (*models.User, error) { + if err := form.Validate(); err != nil { + return nil, err + } + + user, err := form.app.Dao().FindUserByToken( + form.Token, + form.app.Settings().UserVerificationToken.Secret, + ) + if err != nil { + return nil, err + } + + if user.Verified { + return user, nil // already verified + } + + user.Verified = true + + if err := form.app.Dao().SaveUser(user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/forms/user_verification_confirm_test.go b/forms/user_verification_confirm_test.go new file mode 100644 index 000000000..5291eba01 --- /dev/null +++ b/forms/user_verification_confirm_test.go @@ -0,0 +1,140 @@ +package forms_test + +import ( + "encoding/json" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestUserVerificationConfirmValidate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty data + { + `{}`, + []string{"token"}, + }, + // empty fields + { + `{"token":""}`, + []string{"token"}, + }, + // invalid JWT token + { + `{"token":"invalid"}`, + []string{"token"}, + }, + // expired token + { + `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`, + []string{"token"}, + }, + // valid token + { + `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserVerificationConfirm(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserVerificationConfirmSubmit(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + scenarios := []struct { + jsonData string + expectError bool + }{ + // empty data (Validate call check) + { + `{}`, + true, + }, + // expired token (Validate call check) + { + `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`, + true, + }, + // valid token (already verified user) + { + `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`, + false, + }, + // valid token (unverified user) + { + `{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTkwNjEwNjQyMX0.KbSucLGasQqTkGxUgqaaCjKNOHJ3ZVkL1WTzSApc6oM"}`, + false, + }, + } + + for i, s := range scenarios { + form := forms.NewUserVerificationConfirm(app) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + user, err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + if s.expectError { + continue + } + + claims, _ := security.ParseUnverifiedJWT(form.Token) + tokenUserId, _ := claims["id"] + + if user.Id != tokenUserId { + t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id) + } + + if !user.Verified { + t.Errorf("(%d) Expected user.Verified to be true, got false", i) + } + } +} diff --git a/forms/user_verification_request.go b/forms/user_verification_request.go new file mode 100644 index 000000000..54cb39386 --- /dev/null +++ b/forms/user_verification_request.go @@ -0,0 +1,74 @@ +package forms + +import ( + "errors" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/tools/types" +) + +// UserVerificationRequest defines a user email verification request form. +type UserVerificationRequest struct { + app core.App + resendThreshold float64 + + Email string `form:"email" json:"email"` +} + +// NewUserVerificationRequest creates a new user email verification request form. +func NewUserVerificationRequest(app core.App) *UserVerificationRequest { + return &UserVerificationRequest{ + app: app, + resendThreshold: 120, // 2 min + } +} + +// Validate makes the form validatable by implementing [validation.Validatable] interface. +// +// // This method doesn't verify that user with `form.Email` exists (this is done on Submit). +func (form *UserVerificationRequest) Validate() error { + return validation.ValidateStruct(form, + validation.Field( + &form.Email, + validation.Required, + validation.Length(1, 255), + is.Email, + ), + ) +} + +// Submit validates and sends a verification request email +// to the `form.Email` user. +func (form *UserVerificationRequest) Submit() error { + if err := form.Validate(); err != nil { + return err + } + + user, err := form.app.Dao().FindUserByEmail(form.Email) + if err != nil { + return err + } + + if user.Verified { + return nil // already verified + } + + now := time.Now().UTC() + lastVerificationSentAt := user.LastVerificationSentAt.Time() + if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold { + return errors.New("A verification email was already sent.") + } + + if err := mails.SendUserVerification(form.app, user); err != nil { + return err + } + + // update last sent timestamp + user.LastVerificationSentAt = types.NowDateTime() + + return form.app.Dao().SaveUser(user) +} diff --git a/forms/user_verification_request_test.go b/forms/user_verification_request_test.go new file mode 100644 index 000000000..9ece9188e --- /dev/null +++ b/forms/user_verification_request_test.go @@ -0,0 +1,171 @@ +package forms_test + +import ( + "encoding/json" + "testing" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/types" +) + +func TestUserVerificationRequestValidate(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + scenarios := []struct { + jsonData string + expectedErrors []string + }{ + // empty data + { + `{}`, + []string{"email"}, + }, + // empty fields + { + `{"email":""}`, + []string{"email"}, + }, + // invalid email format + { + `{"email":"invalid"}`, + []string{"email"}, + }, + // valid email + { + `{"email":"new@example.com"}`, + []string{}, + }, + } + + for i, s := range scenarios { + form := forms.NewUserVerificationRequest(testApp) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + // parse errors + result := form.Validate() + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("(%d) Failed to parse errors %v", i, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs) + } + } + } +} + +func TestUserVerificationRequestSubmit(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + scenarios := []struct { + jsonData string + expectError bool + expectMail bool + }{ + // empty field (Validate call check) + { + `{"email":""}`, + true, + false, + }, + // invalid email field (Validate call check) + { + `{"email":"invalid"}`, + true, + false, + }, + // nonexisting user + { + `{"email":"missing@example.com"}`, + true, + false, + }, + // existing user (already verified) + { + `{"email":"test@example.com"}`, + false, + false, + }, + // existing user (already verified) - repeating request to test threshod skip + { + `{"email":"test@example.com"}`, + false, + false, + }, + // existing user (unverified) + { + `{"email":"test2@example.com"}`, + false, + true, + }, + // existing user (inverified) - reached send threshod + { + `{"email":"test2@example.com"}`, + true, + false, + }, + } + + now := types.NowDateTime() + time.Sleep(1 * time.Millisecond) + + for i, s := range scenarios { + testApp.TestMailer.TotalSend = 0 // reset + form := forms.NewUserVerificationRequest(testApp) + + // load data + loadErr := json.Unmarshal([]byte(s.jsonData), form) + if loadErr != nil { + t.Errorf("(%d) Failed to load form data: %v", i, loadErr) + continue + } + + err := form.Submit() + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + + expectedMails := 0 + if s.expectMail { + expectedMails = 1 + } + if testApp.TestMailer.TotalSend != expectedMails { + t.Errorf("(%d) Expected %d mail(s) to be sent, got %d", i, expectedMails, testApp.TestMailer.TotalSend) + } + + if s.expectError { + continue + } + + user, err := testApp.Dao().FindUserByEmail(form.Email) + if err != nil { + t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email) + continue + } + + // check whether LastVerificationSentAt was updated + if !user.Verified && user.LastVerificationSentAt.Time().Sub(now.Time()) < 0 { + t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt) + } + } +} diff --git a/forms/validators/file.go b/forms/validators/file.go new file mode 100644 index 000000000..042b0fa34 --- /dev/null +++ b/forms/validators/file.go @@ -0,0 +1,63 @@ +package validators + +import ( + "encoding/binary" + "fmt" + "net/http" + "strings" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/tools/rest" +) + +// UploadedFileSize checks whether the validated `rest.UploadedFile` +// size is no more than the provided maxBytes. +// +// Example: +// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000))) +func UploadedFileSize(maxBytes int) validation.RuleFunc { + return func(value any) error { + v, _ := value.(*rest.UploadedFile) + if v == nil { + return nil // nothing to validate + } + + if binary.Size(v.Bytes()) > maxBytes { + return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes)) + } + + return nil + } +} + +// UploadedFileMimeType checks whether the validated `rest.UploadedFile` +// mimetype is within the provided allowed mime types. +// +// Example: +// validMimeTypes := []string{"test/plain","image/jpeg"} +// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes))) +func UploadedFileMimeType(validTypes []string) validation.RuleFunc { + return func(value any) error { + v, _ := value.(*rest.UploadedFile) + if v == nil { + return nil // nothing to validate + } + + if len(validTypes) == 0 { + return validation.NewError("validation_invalid_mime_type", "Unsupported file type.") + } + + filetype := http.DetectContentType(v.Bytes()) + + for _, t := range validTypes { + if t == filetype { + return nil // valid + } + } + + return validation.NewError("validation_invalid_mime_type", fmt.Sprintf( + "The following mime types are only allowed: %s.", + strings.Join(validTypes, ","), + )) + } +} diff --git a/forms/validators/file_test.go b/forms/validators/file_test.go new file mode 100644 index 000000000..2aa492987 --- /dev/null +++ b/forms/validators/file_test.go @@ -0,0 +1,92 @@ +package validators_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/rest" +) + +func TestUploadedFileSize(t *testing.T) { + data, mp, err := tests.MockMultipartData(nil, "test") + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/", data) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + files, err := rest.FindUploadedFiles(req, "test") + if err != nil { + t.Fatal(err) + } + + if len(files) != 1 { + t.Fatalf("Expected one test file, got %d", len(files)) + } + + scenarios := []struct { + maxBytes int + file *rest.UploadedFile + expectError bool + }{ + {0, nil, false}, + {4, nil, false}, + {3, files[0], true}, // all test files have "test" as content + {4, files[0], false}, + {5, files[0], false}, + } + + for i, s := range scenarios { + err := validators.UploadedFileSize(s.maxBytes)(s.file) + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} + +func TestUploadedFileMimeType(t *testing.T) { + data, mp, err := tests.MockMultipartData(nil, "test") + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/", data) + req.Header.Add("Content-Type", mp.FormDataContentType()) + + files, err := rest.FindUploadedFiles(req, "test") + if err != nil { + t.Fatal(err) + } + + if len(files) != 1 { + t.Fatalf("Expected one test file, got %d", len(files)) + } + + scenarios := []struct { + types []string + file *rest.UploadedFile + expectError bool + }{ + {nil, nil, false}, + {[]string{"image/jpeg"}, nil, false}, + {[]string{}, files[0], true}, + {[]string{"image/jpeg"}, files[0], true}, + // test files are detected as "text/plain; charset=utf-8" content type + {[]string{"image/jpeg", "text/plain; charset=utf-8"}, files[0], false}, + } + + for i, s := range scenarios { + err := validators.UploadedFileMimeType(s.types)(s.file) + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} diff --git a/forms/validators/record_data.go b/forms/validators/record_data.go new file mode 100644 index 000000000..b215bb2b6 --- /dev/null +++ b/forms/validators/record_data.go @@ -0,0 +1,418 @@ +package validators + +import ( + "fmt" + "net/url" + "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/pocketbase/daos" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tools/list" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/types" +) + +var requiredErr = validation.NewError("validation_required", "Missing required value") + +// NewRecordDataValidator creates new [models.Record] data validator +// using the provided record constraints and schema. +// +// Example: +// validator := NewRecordDataValidator(app.Dao(), record, nil) +// err := validator.Validate(map[string]any{"test":123}) +func NewRecordDataValidator( + dao *daos.Dao, + record *models.Record, + uploadedFiles []*rest.UploadedFile, +) *RecordDataValidator { + return &RecordDataValidator{ + dao: dao, + record: record, + uploadedFiles: uploadedFiles, + } +} + +// RecordDataValidator defines a model.Record data validator +// using the provided record constraints and schema. +type RecordDataValidator struct { + dao *daos.Dao + record *models.Record + uploadedFiles []*rest.UploadedFile +} + +// Validate validates the provided `data` by checking it against +// the validator record constraints and schema. +func (validator *RecordDataValidator) Validate(data map[string]any) error { + keyedSchema := validator.record.Collection().Schema.AsMap() + if len(keyedSchema) == 0 { + return nil // no fields to check + } + + if len(data) == 0 { + return validation.NewError("validation_empty_data", "No data to validate") + } + + errs := validation.Errors{} + + // check for unknown fields + for key := range data { + if _, ok := keyedSchema[key]; !ok { + errs[key] = validation.NewError("validation_unknown_field", "Unknown field") + } + } + if len(errs) > 0 { + return errs + } + + for key, field := range keyedSchema { + // normalize value to emulate the same behavior + // when fetching or persisting the record model + value := field.PrepareValue(data[key]) + + // check required constraint + if field.Required && validation.Required.Validate(value) != nil { + errs[key] = requiredErr + continue + } + + // validate field value by its field type + if err := validator.checkFieldValue(field, value); err != nil { + errs[key] = err + continue + } + + // check unique constraint + if field.Unique && !validator.dao.IsRecordValueUnique( + validator.record.Collection(), + key, + value, + validator.record.GetId(), + ) { + errs[key] = validation.NewError("validation_not_unique", "Value must be unique") + continue + } + } + + if len(errs) == 0 { + return nil + } + + return errs +} + +func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField, value any) error { + switch field.Type { + case schema.FieldTypeText: + return validator.checkTextValue(field, value) + case schema.FieldTypeNumber: + return validator.checkNumberValue(field, value) + case schema.FieldTypeBool: + return validator.checkBoolValue(field, value) + case schema.FieldTypeEmail: + return validator.checkEmailValue(field, value) + case schema.FieldTypeUrl: + return validator.checkUrlValue(field, value) + case schema.FieldTypeDate: + return validator.checkDateValue(field, value) + case schema.FieldTypeSelect: + return validator.checkSelectValue(field, value) + case schema.FieldTypeJson: + return validator.checkJsonValue(field, value) + case schema.FieldTypeFile: + return validator.checkFileValue(field, value) + case schema.FieldTypeRelation: + return validator.checkRelationValue(field, value) + case schema.FieldTypeUser: + return validator.checkUserValue(field, value) + } + + return nil +} + +func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField, value any) error { + val, _ := value.(string) + if val == "" { + return nil // nothing to check + } + + options, _ := field.Options.(*schema.TextOptions) + + if options.Min != nil && len(val) < *options.Min { + return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min)) + } + + if options.Max != nil && len(val) > *options.Max { + return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max)) + } + + if options.Pattern != "" { + match, _ := regexp.MatchString(options.Pattern, val) + if !match { + return validation.NewError("validation_invalid_format", "Invalid value format") + } + } + + return nil +} + +func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField, value any) error { + if value == nil { + return nil // nothing to check + } + + val, _ := value.(float64) + options, _ := field.Options.(*schema.NumberOptions) + + if options.Min != nil && val < *options.Min { + return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min)) + } + + if options.Max != nil && val > *options.Max { + return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *options.Max)) + } + + return nil +} + +func (validator *RecordDataValidator) checkBoolValue(field *schema.SchemaField, value any) error { + return nil +} + +func (validator *RecordDataValidator) checkEmailValue(field *schema.SchemaField, value any) error { + val, _ := value.(string) + if val == "" { + return nil // nothing to check + } + + if is.Email.Validate(val) != nil { + return validation.NewError("validation_invalid_email", "Must be a valid email") + } + + options, _ := field.Options.(*schema.EmailOptions) + domain := val[strings.LastIndex(val, "@")+1:] + + // only domains check + if len(options.OnlyDomains) > 0 && !list.ExistInSlice(domain, options.OnlyDomains) { + return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed") + } + + // except domains check + if len(options.ExceptDomains) > 0 && list.ExistInSlice(domain, options.ExceptDomains) { + return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed") + } + + return nil +} + +func (validator *RecordDataValidator) checkUrlValue(field *schema.SchemaField, value any) error { + val, _ := value.(string) + if val == "" { + return nil // nothing to check + } + + if is.URL.Validate(val) != nil { + return validation.NewError("validation_invalid_url", "Must be a valid url") + } + + options, _ := field.Options.(*schema.UrlOptions) + + // extract host/domain + u, _ := url.Parse(val) + host := u.Host + + // only domains check + if len(options.OnlyDomains) > 0 && !list.ExistInSlice(host, options.OnlyDomains) { + return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed") + } + + // except domains check + if len(options.ExceptDomains) > 0 && list.ExistInSlice(host, options.ExceptDomains) { + return validation.NewError("validation_url_domain_not_allowed", "Url domain is not allowed") + } + + return nil +} + +func (validator *RecordDataValidator) checkDateValue(field *schema.SchemaField, value any) error { + val, _ := value.(types.DateTime) + + if val.IsZero() { + if field.Required { + return requiredErr + } + return nil // nothing to check + } + + options, _ := field.Options.(*schema.DateOptions) + + if !options.Min.IsZero() { + if err := validation.Min(options.Min.Time()).Validate(val.Time()); err != nil { + return err + } + } + + if !options.Max.IsZero() { + if err := validation.Max(options.Max.Time()).Validate(val.Time()); err != nil { + return err + } + } + + return nil +} + +func (validator *RecordDataValidator) checkSelectValue(field *schema.SchemaField, value any) error { + normalizedVal := list.ToUniqueStringSlice(value) + if len(normalizedVal) == 0 { + return nil // nothing to check + } + + options, _ := field.Options.(*schema.SelectOptions) + + // check max selected items + if len(normalizedVal) > options.MaxSelect { + return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) + } + + // check against the allowed values + for _, val := range normalizedVal { + if !list.ExistInSlice(val, options.Values) { + return validation.NewError("validation_invalid_value", "Invalid value "+val) + } + } + + return nil +} + +func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField, value any) error { + raw, _ := types.ParseJsonRaw(value) + if len(raw) == 0 { + return nil // nothing to check + } + + if is.JSON.Validate(value) != nil { + return validation.NewError("validation_invalid_json", "Must be a valid json value") + } + + return nil +} + +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} + } + + options, _ := field.Options.(*schema.FileOptions) + + if len(names) > options.MaxSelect { + return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) + } + + // 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) + } + } + } + + for _, file := range files { + // check size + if err := UploadedFileSize(options.MaxSize)(file); err != nil { + return err + } + + // check type + if len(options.MimeTypes) > 0 { + if err := UploadedFileMimeType(options.MimeTypes)(file); err != nil { + return err + } + } + } + + return nil +} + +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} + } + + if len(ids) == 0 { + return nil // nothing to check + } + + options, _ := field.Options.(*schema.RelationOptions) + + if len(ids) > options.MaxSelect { + return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) + } + + // check if the related records exist + // --- + relCollection, err := validator.dao.FindCollectionByNameOrId(options.CollectionId) + if err != nil { + return validation.NewError("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed") + } + + var total int + validator.dao.RecordQuery(relCollection). + Select("count(*)"). + AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)). + Row(&total) + if total != len(ids) { + return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids") + } + // --- + + return nil +} + +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} + } + + if len(ids) == 0 { + return nil // nothing to check + } + + options, _ := field.Options.(*schema.UserOptions) + + if len(ids) > options.MaxSelect { + return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect)) + } + + // check if the related users exist + var total int + validator.dao.UserQuery(). + Select("count(*)"). + AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)). + Row(&total) + if total != len(ids) { + return validation.NewError("validation_missing_users", "Failed to fetch all users with the provided ids") + } + + return nil +} diff --git a/forms/validators/record_data_test.go b/forms/validators/record_data_test.go new file mode 100644 index 000000000..daa34a4d6 --- /dev/null +++ b/forms/validators/record_data_test.go @@ -0,0 +1,1443 @@ +package validators_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/pocketbase/pocketbase/daos" + "github.com/pocketbase/pocketbase/forms/validators" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/models/schema" + "github.com/pocketbase/pocketbase/tests" + "github.com/pocketbase/pocketbase/tools/rest" + "github.com/pocketbase/pocketbase/tools/types" +) + +type testDataFieldScenario struct { + name string + data map[string]any + files []*rest.UploadedFile + expectedErrors []string +} + +func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + collection, _ := app.Dao().FindCollectionByNameOrId("demo") + record := models.NewRecord(collection) + validator := validators.NewRecordDataValidator(app.Dao(), record, nil) + + emptyErr := validator.Validate(map[string]any{}) + if emptyErr == nil { + t.Fatal("Expected error for empty data, got nil") + } + + unknownErr := validator.Validate(map[string]any{"unknown": 123}) + if unknownErr == nil { + t.Fatal("Expected error for unknown data, got nil") + } +} + +func TestRecordDataValidatorValidateText(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + min := 3 + max := 10 + pattern := `^\w+$` + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeText, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeText, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeText, + Options: &schema.TextOptions{ + Min: &min, + Max: &max, + Pattern: pattern, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", "test") + dummy.SetDataValue("field2", "test") + dummy.SetDataValue("field3", "test") + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": "test", + "field2": "test", + "field3": "test", + }, + nil, + []string{"field3"}, + }, + { + "check min constraint", + map[string]any{ + "field1": "test", + "field2": "test", + "field3": strings.Repeat("a", min-1), + }, + nil, + []string{"field3"}, + }, + { + "check max constraint", + map[string]any{ + "field1": "test", + "field2": "test", + "field3": strings.Repeat("a", max+1), + }, + nil, + []string{"field3"}, + }, + { + "check pattern constraint", + map[string]any{ + "field1": nil, + "field2": "test", + "field3": "test!", + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": "test", + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": "test", + "field2": 12345, // test value cast + "field3": "test2", + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateNumber(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + min := 1.0 + max := 150.0 + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeNumber, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeNumber, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeNumber, + Options: &schema.NumberOptions{ + Min: &min, + Max: &max, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", 123) + dummy.SetDataValue("field2", 123) + dummy.SetDataValue("field3", 123) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint + casting", + map[string]any{ + "field1": "invalid", + "field2": "invalid", + "field3": "invalid", + }, + nil, + []string{"field2", "field3"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": 123, + "field2": 123, + "field3": 123, + }, + nil, + []string{"field3"}, + }, + { + "check min constraint", + map[string]any{ + "field1": 0.5, + "field2": 1, + "field3": min - 0.5, + }, + nil, + []string{"field3"}, + }, + { + "check max constraint", + map[string]any{ + "field1": nil, + "field2": max, + "field3": max + 0.5, + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": 1, + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": nil, + "field2": 123, // test value cast + "field3": max, + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateBool(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeBool, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeBool, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeBool, + Options: &schema.BoolOptions{}, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", false) + dummy.SetDataValue("field2", true) + dummy.SetDataValue("field3", true) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint + casting", + map[string]any{ + "field1": "invalid", + "field2": "invalid", + "field3": "invalid", + }, + nil, + []string{"field2"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": true, + "field2": true, + "field3": true, + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": 1, + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": false, + "field2": true, + "field3": false, + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateEmail(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeEmail, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeEmail, + Options: &schema.EmailOptions{ + ExceptDomains: []string{"example.com"}, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeEmail, + Options: &schema.EmailOptions{ + OnlyDomains: []string{"example.com"}, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", "test@demo.com") + dummy.SetDataValue("field2", "test@test.com") + dummy.SetDataValue("field3", "test@example.com") + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check email format validator", + map[string]any{ + "field1": "test", + "field2": "test.com", + "field3": 123, + }, + nil, + []string{"field1", "field2", "field3"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": "test@example.com", + "field2": "test@test.com", + "field3": "test@example.com", + }, + nil, + []string{"field3"}, + }, + { + "check ExceptDomains constraint", + map[string]any{ + "field1": "test@example.com", + "field2": "test@example.com", + "field3": "test2@example.com", + }, + nil, + []string{"field2"}, + }, + { + "check OnlyDomains constraint", + map[string]any{ + "field1": "test@test.com", + "field2": "test@test.com", + "field3": "test@test.com", + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": "test@test.com", + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": "123@example.com", + "field2": "test@test.com", + "field3": "test2@example.com", + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateUrl(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeUrl, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeUrl, + Options: &schema.UrlOptions{ + ExceptDomains: []string{"example.com"}, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeUrl, + Options: &schema.UrlOptions{ + OnlyDomains: []string{"example.com"}, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", "http://demo.com") + dummy.SetDataValue("field2", "http://test.com") + dummy.SetDataValue("field3", "http://example.com") + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check url format validator", + map[string]any{ + "field1": "/abc", + "field2": "test.com", // valid + "field3": "test@example.com", + }, + nil, + []string{"field1", "field3"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": "http://example.com", + "field2": "http://test.com", + "field3": "http://example.com", + }, + nil, + []string{"field3"}, + }, + { + "check ExceptDomains constraint", + map[string]any{ + "field1": "http://example.com", + "field2": "http://example.com", + "field3": "https://example.com", + }, + nil, + []string{"field2"}, + }, + { + "check OnlyDomains constraint", + map[string]any{ + "field1": "http://test.com/abc", + "field2": "http://test.com/abc", + "field3": "http://test.com/abc", + }, + nil, + []string{"field3"}, + }, + { + "check subdomains constraint", + map[string]any{ + "field1": "http://test.test.com", + "field2": "http://test.example.com", + "field3": "http://test.example.com", + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": "http://sub.test.com/abc", + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": "http://example.com/123", + "field2": "http://test.com/", + "field3": "http://example.com/test2", + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateDate(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + min, _ := types.ParseDateTime("2022-01-01 01:01:01.123") + max, _ := types.ParseDateTime("2030-01-01 01:01:01") + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeDate, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeDate, + Options: &schema.DateOptions{ + Min: min, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeDate, + Options: &schema.DateOptions{ + Max: max, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", "2022-01-01 01:01:01") + dummy.SetDataValue("field2", "2029-01-01 01:01:01.123") + dummy.SetDataValue("field3", "2029-01-01 01:01:01.123") + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint + cast", + map[string]any{ + "field1": "invalid", + "field2": "invalid", + "field3": "invalid", + }, + nil, + []string{"field2"}, + }, + { + "check required constraint + zero datetime", + map[string]any{ + "field1": "January 1, year 1, 00:00:00 UTC", + "field2": "0001-01-01 00:00:00", + "field3": "0001-01-01 00:00:00 +0000 UTC", + }, + nil, + []string{"field2"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": "2029-01-01 01:01:01.123", + "field2": "2029-01-01 01:01:01.123", + "field3": "2029-01-01 01:01:01.123", + }, + nil, + []string{"field3"}, + }, + { + "check min date constraint", + map[string]any{ + "field1": "2021-01-01 01:01:01", + "field2": "2021-01-01 01:01:01", + "field3": "2021-01-01 01:01:01", + }, + nil, + []string{"field2"}, + }, + { + "check max date constraint", + map[string]any{ + "field1": "2030-02-01 01:01:01", + "field2": "2030-02-01 01:01:01", + "field3": "2030-02-01 01:01:01", + }, + nil, + []string{"field3"}, + }, + { + "valid data (only required)", + map[string]any{ + "field2": "2029-01-01 01:01:01", + }, + nil, + []string{}, + }, + { + "valid data (all)", + map[string]any{ + "field1": "2029-01-01 01:01:01.000", + "field2": "2029-01-01 01:01:01", + "field3": "2029-01-01 01:01:01.456", + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateSelect(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeSelect, + Options: &schema.SelectOptions{ + Values: []string{"1", "a", "b", "c"}, + MaxSelect: 1, + }, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeSelect, + Options: &schema.SelectOptions{ + Values: []string{"a", "b", "c"}, + MaxSelect: 2, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeSelect, + Options: &schema.SelectOptions{ + Values: []string{"a", "b", "c"}, + MaxSelect: 99, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", "a") + dummy.SetDataValue("field2", []string{"a", "b"}) + dummy.SetDataValue("field3", []string{"a", "b", "c"}) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - empty values", + map[string]any{ + "field1": "", + "field2": "", + "field3": "", + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - multiple select cast", + map[string]any{ + "field1": "a", + "field2": "a", + "field3": "a", + }, + nil, + []string{}, + }, + { + "check unique constraint", + map[string]any{ + "field1": "a", + "field2": "b", + "field3": []string{"a", "b", "c"}, + }, + nil, + []string{"field3"}, + }, + { + "check unique constraint - same elements but different order", + map[string]any{ + "field1": "a", + "field2": "b", + "field3": []string{"a", "c", "b"}, + }, + nil, + []string{}, + }, + { + "check Values constraint", + map[string]any{ + "field1": 1, + "field2": "d", + "field3": 123, + }, + nil, + []string{"field2", "field3"}, + }, + { + "check MaxSelect constraint", + map[string]any{ + "field1": []string{"a", "b"}, // this will be normalized to a single string value + "field2": []string{"a", "b", "c"}, + "field3": []string{"a", "b", "b", "b"}, // repeating values will be merged + }, + nil, + []string{"field2"}, + }, + { + "valid data - only required fields", + map[string]any{ + "field2": []string{"a", "b"}, + }, + nil, + []string{}, + }, + { + "valid data - all fields with normalizations", + map[string]any{ + "field1": "a", + "field2": []string{"a", "b", "b"}, // will be collapsed + "field3": "b", // will be normalzied to slice + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateJson(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeJson, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeJson, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeJson, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", `{"test":123}`) + dummy.SetDataValue("field2", `{"test":123}`) + dummy.SetDataValue("field3", `{"test":123}`) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint - nil", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - zero string", + map[string]any{ + "field1": "", + "field2": "", + "field3": "", + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - zero number", + map[string]any{ + "field1": 0, + "field2": 0, + "field3": 0, + }, + nil, + []string{}, + }, + { + "check required constraint - zero slice", + map[string]any{ + "field1": []string{}, + "field2": []string{}, + "field3": []string{}, + }, + nil, + []string{}, + }, + { + "check required constraint - zero map", + map[string]any{ + "field1": map[string]string{}, + "field2": map[string]string{}, + "field3": map[string]string{}, + }, + nil, + []string{}, + }, + { + "check unique constraint", + map[string]any{ + "field1": `{"test":123}`, + "field2": `{"test":123}`, + "field3": map[string]any{"test": 123}, + }, + nil, + []string{"field3"}, + }, + { + "check json text validator", + map[string]any{ + "field1": `[1, 2, 3`, + "field2": `invalid`, + "field3": `null`, // valid + }, + nil, + []string{"field1", "field2"}, + }, + { + "valid data - only required fields", + map[string]any{ + "field2": `{"test":123}`, + }, + nil, + []string{}, + }, + { + "valid data - all fields with normalizations", + map[string]any{ + "field1": []string{"a", "b", "c"}, + "field2": 123, + "field3": `"test"`, + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateFile(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeFile, + Options: &schema.FileOptions{ + MaxSelect: 1, + MaxSize: 3, + }, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeFile, + Options: &schema.FileOptions{ + MaxSelect: 2, + MaxSize: 10, + MimeTypes: []string{"image/jpeg", "text/plain; charset=utf-8"}, + }, + }, + &schema.SchemaField{ + Name: "field3", + Type: schema.FieldTypeFile, + Options: &schema.FileOptions{ + MaxSelect: 3, + MaxSize: 10, + MimeTypes: []string{"image/jpeg"}, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // stub uploaded files + data, mp, err := tests.MockMultipartData(nil, "test", "test", "test", "test", "test") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/", data) + req.Header.Add("Content-Type", mp.FormDataContentType()) + testFiles, err := rest.FindUploadedFiles(req, "test") + if err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint - nil", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check MaxSelect constraint", + map[string]any{ + "field1": "test1", + "field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()}, + "field3": []string{"test1", "test2", "test3", "test4"}, + }, + []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]}, + []string{"field2", "field3"}, + }, + { + "check MaxSize constraint", + map[string]any{ + "field1": testFiles[0].Name(), + "field2": []string{"test1", testFiles[0].Name()}, + "field3": []string{"test1", "test2", "test3"}, + }, + []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]}, + []string{"field1"}, + }, + { + "check MimeTypes constraint", + map[string]any{ + "field1": "test1", + "field2": []string{"test1", testFiles[0].Name()}, + "field3": []string{testFiles[1].Name(), testFiles[2].Name()}, + }, + []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]}, + []string{"field3"}, + }, + { + "valid data - no new files (just file ids)", + map[string]any{ + "field1": "test1", + "field2": []string{"test1", "test2"}, + "field3": []string{"test1", "test2", "test3"}, + }, + nil, + []string{}, + }, + { + "valid data - just new files", + map[string]any{ + "field1": nil, + "field2": []string{testFiles[0].Name(), testFiles[1].Name()}, + "field3": nil, + }, + []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]}, + []string{}, + }, + { + "valid data - mixed existing and new files", + map[string]any{ + "field1": "test1", + "field2": []string{"test1", testFiles[0].Name()}, + "field3": "test1", // will be casted + }, + []*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]}, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateRelation(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + demo, _ := app.Dao().FindCollectionByNameOrId("demo4") + + // demo4 rel ids + relId1 := "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b" + relId2 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125" + relId3 := "b84cd893-7119-43c9-8505-3c4e22da28a9" + relId4 := "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2" + + // record rel ids from different collections + diffRelId1 := "63c2ab80-84ab-4057-a592-4604a731f78f" + diffRelId2 := "2c542824-9de1-42fe-8924-e57c86267760" + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeRelation, + Options: &schema.RelationOptions{ + MaxSelect: 1, + CollectionId: demo.Id, + }, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeRelation, + Options: &schema.RelationOptions{ + MaxSelect: 2, + CollectionId: demo.Id, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeRelation, + Options: &schema.RelationOptions{ + MaxSelect: 3, + CollectionId: demo.Id, + }, + }, + &schema.SchemaField{ + Name: "field4", + Type: schema.FieldTypeRelation, + Options: &schema.RelationOptions{ + MaxSelect: 3, + CollectionId: "", // missing or nonexisting colleciton id + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", relId1) + dummy.SetDataValue("field2", []string{relId1, relId2}) + dummy.SetDataValue("field3", []string{relId1, relId2, relId3}) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint - nil", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - zero id", + map[string]any{ + "field1": "", + "field2": "", + "field3": "", + }, + nil, + []string{"field2"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": relId1, + "field2": relId2, + "field3": []string{relId1, relId2, relId3, relId3}, // repeating values are collapsed + }, + nil, + []string{"field3"}, + }, + { + "check nonexisting collection id", + map[string]any{ + "field2": relId1, + "field4": relId1, + }, + nil, + []string{"field4"}, + }, + { + "check MaxSelect constraint", + map[string]any{ + "field1": []string{relId1, relId2}, // will be normalized to relId1 only + "field2": []string{relId1, relId2, relId3}, + "field3": []string{relId1, relId2, relId3, relId4}, + }, + nil, + []string{"field2", "field3"}, + }, + { + "check with ids from different collections", + map[string]any{ + "field1": diffRelId1, + "field2": []string{relId2, diffRelId1}, + "field3": []string{diffRelId1, diffRelId2}, + }, + nil, + []string{"field1", "field2", "field3"}, + }, + { + "valid data - only required fields", + map[string]any{ + "field2": []string{relId1, relId2}, + }, + nil, + []string{}, + }, + { + "valid data - all fields with normalization", + map[string]any{ + "field1": []string{relId1, relId2}, + "field2": relId2, + "field3": []string{relId3, relId2, relId1}, // unique is not triggered because the order is different + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func TestRecordDataValidatorValidateUser(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + userId1 := "97cc3d3d-6ba2-383f-b42a-7bc84d27410c" + userId2 := "7bc84d27-6ba2-b42a-383f-4197cc3d3d0c" + userId3 := "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c" + missingUserId := "00000000-84ab-4057-a592-4604a731f78f" + + // create new test collection + collection := &models.Collection{} + collection.Name = "validate_test" + collection.Schema = schema.NewSchema( + &schema.SchemaField{ + Name: "field1", + Type: schema.FieldTypeUser, + Options: &schema.UserOptions{ + MaxSelect: 1, + }, + }, + &schema.SchemaField{ + Name: "field2", + Required: true, + Type: schema.FieldTypeUser, + Options: &schema.UserOptions{ + MaxSelect: 2, + }, + }, + &schema.SchemaField{ + Name: "field3", + Unique: true, + Type: schema.FieldTypeUser, + Options: &schema.UserOptions{ + MaxSelect: 3, + }, + }, + ) + if err := app.Dao().SaveCollection(collection); err != nil { + t.Fatal(err) + } + + // create dummy record (used for the unique check) + dummy := models.NewRecord(collection) + dummy.SetDataValue("field1", userId1) + dummy.SetDataValue("field2", []string{userId1, userId2}) + dummy.SetDataValue("field3", []string{userId1, userId2, userId3}) + if err := app.Dao().SaveRecord(dummy); err != nil { + t.Fatal(err) + } + + scenarios := []testDataFieldScenario{ + { + "check required constraint - nil", + map[string]any{ + "field1": nil, + "field2": nil, + "field3": nil, + }, + nil, + []string{"field2"}, + }, + { + "check required constraint - zero id", + map[string]any{ + "field1": "", + "field2": "", + "field3": "", + }, + nil, + []string{"field2"}, + }, + { + "check unique constraint", + map[string]any{ + "field1": nil, + "field2": userId1, + "field3": []string{userId1, userId2, userId3, userId3}, // repeating values are collapsed + }, + nil, + []string{"field3"}, + }, + { + "check MaxSelect constraint", + map[string]any{ + "field1": []string{userId1, userId2}, // maxSelect is 1 and will be normalized to userId1 only + "field2": []string{userId1, userId2, userId3}, + "field3": []string{userId1, userId3, userId2}, + }, + nil, + []string{"field2"}, + }, + { + "check with mixed existing and nonexisting user ids", + map[string]any{ + "field1": missingUserId, + "field2": []string{missingUserId, userId1}, + "field3": []string{userId1, missingUserId}, + }, + nil, + []string{"field1", "field2", "field3"}, + }, + { + "valid data - only required fields", + map[string]any{ + "field2": []string{userId1, userId2}, + }, + nil, + []string{}, + }, + { + "valid data - all fields with normalization", + map[string]any{ + "field1": []string{userId1, userId2}, + "field2": userId2, + "field3": []string{userId3, userId2, userId1}, // unique is not triggered because the order is different + }, + nil, + []string{}, + }, + } + + checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios) +} + +func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) { + for i, s := range scenarios { + validator := validators.NewRecordDataValidator(dao, record, s.files) + result := validator.Validate(s.data) + + prefix := fmt.Sprintf("%d", i) + if s.name != "" { + prefix = s.name + } + + // parse errors + errs, ok := result.(validation.Errors) + if !ok && result != nil { + t.Errorf("[%s] Failed to parse errors %v", prefix, result) + continue + } + + // check errors + if len(errs) > len(s.expectedErrors) { + t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs) + } + for _, k := range s.expectedErrors { + if _, ok := errs[k]; !ok { + t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs) + } + } + } +} diff --git a/forms/validators/string.go b/forms/validators/string.go new file mode 100644 index 000000000..10f5202a9 --- /dev/null +++ b/forms/validators/string.go @@ -0,0 +1,21 @@ +package validators + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +// Compare checks whether the validated value matches another string. +// +// Example: +// validation.Field(&form.PasswordConfirm, validation.By(validators.Compare(form.Password))) +func Compare(valueToCompare string) validation.RuleFunc { + return func(value any) error { + v, _ := value.(string) + + if v != valueToCompare { + return validation.NewError("validation_values_mismatch", "Values don't match.") + } + + return nil + } +} diff --git a/forms/validators/string_test.go b/forms/validators/string_test.go new file mode 100644 index 000000000..e9a0c6a00 --- /dev/null +++ b/forms/validators/string_test.go @@ -0,0 +1,30 @@ +package validators_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/forms/validators" +) + +func TestCompare(t *testing.T) { + scenarios := []struct { + valA string + valB string + expectError bool + }{ + {"", "", false}, + {"", "456", true}, + {"123", "", true}, + {"123", "456", true}, + {"123", "123", false}, + } + + for i, s := range scenarios { + err := validators.Compare(s.valA)(s.valB) + + hasErr := err != nil + if hasErr != s.expectError { + t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err) + } + } +} diff --git a/forms/validators/validators.go b/forms/validators/validators.go new file mode 100644 index 000000000..ec8c21777 --- /dev/null +++ b/forms/validators/validators.go @@ -0,0 +1,2 @@ +// Package validators implements custom shared PocketBase validators. +package validators diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..3d31fa804 --- /dev/null +++ b/go.mod @@ -0,0 +1,87 @@ +module github.com/pocketbase/pocketbase + +go 1.18 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.5 + github.com/aws/aws-sdk-go v1.44.48 + github.com/disintegration/imaging v1.6.2 + github.com/domodwyer/mailyak/v3 v3.3.3 + github.com/fatih/color v1.13.0 + github.com/ganigeorgiev/fexpr v0.1.1 + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 + github.com/golang-jwt/jwt/v4 v4.4.2 + github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198 + github.com/mattn/go-sqlite3 v1.14.14 + github.com/microcosm-cc/bluemonday v1.0.19 + github.com/pocketbase/dbx v1.6.0 + github.com/spf13/cast v1.5.0 + github.com/spf13/cobra v1.5.0 + gocloud.dev v0.25.0 + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d + golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 + modernc.org/sqlite v1.17.3 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect + github.com/aws/aws-sdk-go-v2 v1.16.7 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.15.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.12.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.11.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 // indirect + github.com/aws/smithy-go v1.12.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/google/wire v0.5.0 // indirect + github.com/googleapis/gax-go/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.1 // indirect + go.opencensus.io v0.23.0 // indirect + golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/net v0.0.0-20220706163947-c90051bbdb60 // indirect + golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e // indirect + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect + golang.org/x/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect + golang.org/x/tools v0.1.11 // indirect + golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect + google.golang.org/api v0.86.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20220706132729-d86698d07c53 // indirect + google.golang.org/grpc v1.47.0 // indirect + google.golang.org/protobuf v1.28.0 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.36.0 // indirect + modernc.org/ccgo/v3 v3.16.6 // indirect + modernc.org/libc v1.16.14 // indirect + modernc.org/mathutil v1.4.1 // indirect + modernc.org/memory v1.1.1 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.2 // indirect + modernc.org/token v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..7f10de2de --- /dev/null +++ b/go.sum @@ -0,0 +1,1151 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.82.0/go.mod h1:vlKccHJGuFBFufnAnuB08dfEH9Y3H7dzDzRECFdC2TA= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8= +cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk= +cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= +cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw= +cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/kms v1.1.0/go.mod h1:WdbppnCDMDpOvoYBMn1+gNmOeEoZYqAv+HeuKARGCXI= +cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= +cloud.google.com/go/monitoring v1.1.0/go.mod h1:L81pzz7HKn14QCMaCs6NTQkdBnE87TElyanS95vIcl4= +cloud.google.com/go/monitoring v1.4.0/go.mod h1:y6xnxfwI3hTFWOdkOaD7nfJVlwuC3/mS/5kvtT131p4= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.19.0/go.mod h1:/O9kmSe9bb9KRnIAWkzmqhPjHo6LtzGOBYd/kr06XSs= +cloud.google.com/go/secretmanager v1.3.0/go.mod h1:+oLTkouyiYiabAQNugCeTS3PAArGiMJuBqvJnJsyH+U= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA= +cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg= +cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= +cloud.google.com/go/trace v1.0.0/go.mod h1:4iErSByzxkyHWzzlAj63/Gmjz0NH1ASqhJguHpGcr6A= +cloud.google.com/go/trace v1.2.0/go.mod h1:Wc8y/uYyOhPy12KEnXG9XGrvfMz5F5SrYecQlbW1rwM= +contrib.go.opencensus.io/exporter/aws v0.0.0-20200617204711-c478e41e60e9/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= +contrib.go.opencensus.io/exporter/stackdriver v0.13.10/go.mod h1:I5htMbyta491eUxufwwZPQdcKvvgzMB4O9ni41YnIM8= +contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ= +github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= +github.com/Azure/azure-amqp-common-go/v3 v3.2.1/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI= +github.com/Azure/azure-amqp-common-go/v3 v3.2.2/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI= +github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U= +github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= +github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= +github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= +github.com/Azure/azure-storage-blob-go v0.14.0 h1:1BCg74AmVdYwO3dlKwtFU1V0wU2PZdREkXvAmZJRUlM= +github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck= +github.com/Azure/go-amqp v0.16.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= +github.com/Azure/go-amqp v0.16.4/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= +github.com/Azure/go-autorest/autorest v0.11.19/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= +github.com/Azure/go-autorest/autorest v0.11.22/go.mod h1:BAWYUWGPEtKPzjVkp0Q6an0MJcJDsoh5Z1BFAEFs4Xs= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= +github.com/Azure/go-autorest/autorest/adal v0.9.14/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M= +github.com/Azure/go-autorest/autorest/adal v0.9.17/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.9/go.mod h1:hg3/1yw0Bq87O3KvvnJoAh34/0zbP7SFizX/qN5JvjU= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= +github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GoogleCloudPlatform/cloudsql-proxy v1.29.0/go.mod h1:spvB9eLJH9dutlbPSRmHvSXXHOwGRyeXh1jVdquA2G8= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ= +github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= +github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go v1.44.48 h1:jLDC9RsNoYMLFlKpB8LdqUnoDdC2yvkS4QbuyPQJ8+M= +github.com/aws/aws-sdk-go v1.44.48/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= +github.com/aws/aws-sdk-go-v2 v1.16.7 h1:zfBwXus3u14OszRxGcqCDS4MfMCv10e8SMJ2r8Xm0Ns= +github.com/aws/aws-sdk-go-v2 v1.16.7/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.1/go.mod h1:n8Bs1ElDD2wJ9kCRTczA83gYbBmjSwZp3umc6zF4EeM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 h1:S/ZBwevQkr7gv5YxONYpGQxlMFFYSRfz3RMcjsC9Qhk= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y= +github.com/aws/aws-sdk-go-v2/config v1.15.3/go.mod h1:9YL3v07Xc/ohTsxFXzan9ZpFpdTOFl4X65BAKYaz8jg= +github.com/aws/aws-sdk-go-v2/config v1.15.13 h1:CJH9zn/Enst7lDiGpoguVt0lZr5HcpNVlRJWbJ6qreo= +github.com/aws/aws-sdk-go-v2/config v1.15.13/go.mod h1:AcMu50uhV6wMBUlURnEXhr9b3fX6FLSTlEV89krTEGk= +github.com/aws/aws-sdk-go-v2/credentials v1.11.2/go.mod h1:j8YsY9TXTm31k4eFhspiQicfXPLZ0gYXA50i4gxPE8g= +github.com/aws/aws-sdk-go-v2/credentials v1.12.8 h1:niTa7zc7uyOP2ufri0jPESBt1h9yP3Zc0q+xzih3h8o= +github.com/aws/aws-sdk-go-v2/credentials v1.12.8/go.mod h1:P2Hd4Sy7mXRxPNcQMPBmqszSJoDXexX8XEDaT6lucO0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.3/go.mod h1:uk1vhHHERfSVCUnqSqz8O48LBYDSC+k6brng09jcMOk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 h1:VfBdn2AxwMbFyJN/lF/xuT3SakomJ86PZu3rCxb5K0s= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8/go.mod h1:oL1Q3KuCq1D4NykQnIvtRiBGLUXhcpY5pl6QZB2XEPU= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.3/go.mod h1:0dHuD2HZZSiwfJSy1FO5bX1hQ1TxVV1QXXjpn3XUE44= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19 h1:WfCYqsAADDRNCQQ5LGcrlqbR7SK3PYrP/UCh7qNGBQM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.19/go.mod h1:koLPv2oF6ksE3zBKLDP0GFmKfaCmYwVHqGIbaPrHIRg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 h1:2C0pYHcUBmdzPj+EKNC4qj97oK6yjrUhc1KoSodglvk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14/go.mod h1:kdjrMwHwrC3+FsKhNcCMJ7tUVj/8uSD5CZXeQ4wV6fM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 h1:2J+jdlBJWEmTyAwC82Ym68xCykIvnSnIN18b8xHGlcc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8/go.mod h1:ZIV8GYoC6WLBW5KGs+o4rsc65/ozd+eQ0L31XF5VDwk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.10/go.mod h1:8DcYQcz0+ZJaSxANlHIsbbi6S+zMwjwdDqwW3r9AzaE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 h1:QquxR7NH3ULBsKC+NoTpilzbKKS+5AELfNREInbhvas= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15/go.mod h1:Tkrthp/0sNBShQQsamR7j/zY4p19tVTAs+nnqhH6R3c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 h1:tEEHn+PGAxRVqMPEhtU8oCSW/1Ge3zP5nUgPrGQNUPs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5/go.mod h1:aIwFF3dUk95ocCcA3zfk3nhz0oLkpzHFWuMp8l/4nNs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.1/go.mod h1:GeUru+8VzrTXV/83XyMJ80KpH8xO89VPoUileyNQ+tc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 h1:4n4KCtv5SUoT5Er5XV41huuzrCqepxlW3SDI9qHQebc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.3/go.mod h1:Seb8KNmD6kVTjwRjVEgOT5hPin6sq+v4C2ycJQDwuH8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 h1:gVv2vXOMqJeR4ZHHV32K7LElIJIIzyw/RU1b0lSfWTQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9/go.mod h1:EF5RLnD9l0xvEWwMRcktIS/dI6lF8lU5eV3B13k6sWo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.3/go.mod h1:wlY6SVjuwvh3TVRpTqdy4I1JpBFLX4UGeKZdWntaocw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8 h1:oKnAXxSF2FUvfgw8uzU/v9OTYorJJZ8eBmWhr9TWVVQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8/go.mod h1:rDVhIMAX9N2r8nWxDUlbubvvaFMnfsm+3jAV7q+rpM4= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.3/go.mod h1:Bm/v2IaN6rZ+Op7zX+bOUMdL4fsrYZiD0dsjLhNKwZc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8 h1:TlN1UC39A0LUNoD51ubO5h32haznA+oVe15jO9O4Lj0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8/go.mod h1:JlVwmWtT/1c5W+6oUsjXjAJ0iJZ+hlghdrDy/8JxGCU= +github.com/aws/aws-sdk-go-v2/service/kms v1.16.3/go.mod h1:QuiHPBqlOFCi4LqdSskYYAWpQlx3PKmohy+rE2F+o5g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.26.3/go.mod h1:g1qvDuRsJY+XghsV6zg00Z4KJ7DtFFCx8fJD2a491Ak= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1 h1:OKQIQ0QhEBmGr2LfT952meIZz3ujrPYnxH+dO/5ldnI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1/go.mod h1:NffjpNsMUFXp6Ok/PahrktAncoekWrywvmIK83Q2raE= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.4/go.mod h1:PJc8s+lxyU8rrre0/4a0pn2wgwiDvOEzoOjcJUBr67o= +github.com/aws/aws-sdk-go-v2/service/sns v1.17.4/go.mod h1:kElt+uCcXxcqFyc+bQqZPFD9DME/eC6oHBXvFzQ9Bcw= +github.com/aws/aws-sdk-go-v2/service/sqs v1.18.3/go.mod h1:skmQo0UPvsjsuYYSYMVmrPc1HWCbHUJyrCEp+ZaLzqM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.24.1/go.mod h1:NR/xoKjdbRJ+qx0pMR4mI+N/H1I1ynHwXnO6FowXJc0= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.3/go.mod h1:7UQ/e69kU7LDPtY40OyoHYgRmgfGM4mgsLYtcObdveU= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.11 h1:XOJWXNFXJyapJqQuCIPfftsOf0XZZioM0kK6OPRt9MY= +github.com/aws/aws-sdk-go-v2/service/sso v1.11.11/go.mod h1:MO4qguFjs3wPGcCSpQ7kOFTwRvb+eu+fn+1vKleGHUk= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.3/go.mod h1:bfBj0iVmsUyUg4weDB4NxktD9rDGeKSVWnjTnwbx9b8= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 h1:yOfILxyjmtr2ubRkRJldlHDFBhf5vw4CzhbwWIBmimQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.16.9/go.mod h1:O1IvkYxr+39hRf960Us6j0x1P8pDqhTX+oXM5kQNl/Y= +github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= +github.com/aws/smithy-go v1.12.0 h1:gXpeZel/jPoWQ7OEmLIgCUnhkFftqNfwWUwAHSlp1v0= +github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU= +github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= +github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/domodwyer/mailyak/v3 v3.3.3 h1:E9cjqDUiwY1QSE5G2CbWHM7EJV5FybKPHnGovc2iaA8= +github.com/domodwyer/mailyak/v3 v3.3.3/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/ganigeorgiev/fexpr v0.1.1 h1:La9kYEgTcIutvOnqNZ8pOUD0O0Q/Gn15sTVEX+IeBE8= +github.com/ganigeorgiev/fexpr v0.1.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.7.3/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-replayers/grpcreplay v1.1.0 h1:S5+I3zYyZ+GQz68OfbURDdt/+cSMqCK1wrvNx7WBzTE= +github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk= +github.com/google/go-replayers/httpreplay v1.1.1 h1:H91sIMlt1NZzN7R+/ASswyouLJfW0WLW7fhyUFvDEkY= +github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible h1:xmapqc1AyLoB+ddYT6r04bD9lIjlOqGaREovi0SzFaE= +github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210506205249-923b5ab0fc1a/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= +github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA= +github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= +github.com/hanwen/go-fuse/v2 v2.1.0/go.mod h1:oRyA5eK+pvJyv5otpO/DgccS8y/RvYMaO00GgRLGryc= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.11.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.10.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.15.0/go.mod h1:D/zyOyXiaM1TmVWnOM18p0xdDtdakRBa0RsVGI3U3bw= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198 h1:lFz33AOOXwTpqOiHvrN8nmTdkxSfuNLHLPjgQ1muPpU= +github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198/go.mod h1:uh3YlzsEJj7OG57rDWj6c3WEkOF1ZHGBQkDuUZw3rE8= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= +github.com/mattn/go-ieproxy v0.0.3 h1:YkaHmK1CzE5C4O7A3hv3TCbfNDPSCf0RKZFX+VhBeYk= +github.com/mattn/go-ieproxy v0.0.3/go.mod h1:6ZpRmhBaYuBX1U2za+9rC9iCGLsSp2tftelZne7CPko= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= +github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c= +github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pocketbase/dbx v1.6.0 h1:iPQi99GpaMRne0KRVnd/kCfxayCP/f4QDb6hGxMRI3I= +github.com/pocketbase/dbx v1.6.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +gocloud.dev v0.25.0 h1:Y7vDq8xj7SyM848KXf32Krda2e6jQ4CLh/mTeCSqXtk= +gocloud.dev v0.25.0/go.mod h1:7HegHVCYZrMiU3IE1qtnzf/vRrDwLYnRNR3EhWX8x9Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw= +golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191112182307-2180aed22343/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220706163947-c90051bbdb60 h1:8NSylCMxLW4JvserAndSgFL7aPli6A68yf0bYFTcWCM= +golang.org/x/net v0.0.0-20220706163947-c90051bbdb60/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0 h1:VnGaRqoLmqZH/3TMLJwYCEWkR4j1nuIU1U9TvbqsDUw= +golang.org/x/oauth2 v0.0.0-20220630143837-2104d58473e0/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e h1:CsOuNlbOuf0mzxJIefr6Q4uAUetRUwZE4qt7VfzP+xo= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U= +golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY= +golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.46.0/go.mod h1:ceL4oozhkAiTID8XMmJBsIxID/9wMXJVVFXPg4ylg3I= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM= +google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.68.0/go.mod h1:sOM8pTpwgflXRhz+oC8H2Dr+UcbMqkPPWNJo88Q7TH8= +google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= +google.golang.org/api v0.86.0 h1:ZAnyOHQFIuWso1BodVfSaRyffD74T9ERGFa3k1fNk/U= +google.golang.org/api v0.86.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210429181445-86c259c2b4ab/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210517163617-5e0236093d7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211018162055-cf77aa76bad2/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220204002441-d6cc3cc0770e/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220401170504-314d38edb7de/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220706132729-d86698d07c53 h1:liFd7OL799HvMNYG5xozhUoWDj944y+zXPDOhu4PyaM= +google.golang.org/genproto v0.0.0-20220706132729-d86698d07c53/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.0 h1:0kmRkTmqNidmu3c7BNDSdVHCxXCkWLmWmCIVX4LUboo= +modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= +modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= +modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.6 h1:3l18poV+iUemQ98O3X5OMr97LOqlzis+ytivU4NqGhA= +modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= +modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= +modernc.org/libc v1.16.7/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= +modernc.org/libc v1.16.14 h1:MUIjk9Xwlkrp0BqGhMfRkiq0EkZsqfNiP4eixL3YiPk= +modernc.org/libc v1.16.14/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.1.1 h1:bDOL0DIDLQv7bWhP3gMvIrnoFw+Eo6F7a2QK9HPDiFU= +modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.17.3 h1:iE+coC5g17LtByDYDWKpR6m2Z9022YrSh3bumwOnIrI= +modernc.org/sqlite v1.17.3/go.mod h1:10hPVYar9C0kfXuTWGz8s0XtB8uAGymUy51ZzStYe3k= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.2 h1:iFBDH6j1Z0bN/Q9udJnnFoFpENA4252qe/7/5woE5MI= +modernc.org/strutil v1.1.2/go.mod h1:OYajnUAcI/MX+XD/Wx7v1bbdvcQSvxgtb0gC+u3d3eg= +modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= +modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= +modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/mails/admin.go b/mails/admin.go new file mode 100644 index 000000000..252e069d9 --- /dev/null +++ b/mails/admin.go @@ -0,0 +1,76 @@ +package mails + +import ( + "fmt" + "net/mail" + "strings" + + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/mails/templates" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tokens" +) + +// SendAdminPasswordReset sends a password reset request email to the specified admin. +func SendAdminPasswordReset(app core.App, admin *models.Admin) error { + token, tokenErr := tokens.NewAdminResetPasswordToken(app, admin) + if tokenErr != nil { + return tokenErr + } + + actionUrl, urlErr := normalizeUrl(fmt.Sprintf( + "%s/#/confirm-password-reset/%s", + strings.TrimSuffix(app.Settings().Meta.AppUrl, "/"), + token, + )) + if urlErr != nil { + return urlErr + } + + params := struct { + AppName string + AppUrl string + Admin *models.Admin + Token string + ActionUrl string + }{ + AppName: app.Settings().Meta.AppName, + AppUrl: app.Settings().Meta.AppUrl, + Admin: admin, + Token: token, + ActionUrl: actionUrl, + } + + mailClient := app.NewMailClient() + + event := &core.MailerAdminEvent{ + MailClient: mailClient, + Admin: admin, + Meta: map[string]any{"token": token}, + } + + sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error { + // resolve body template + body, renderErr := resolveTemplateContent(params, templates.Layout, templates.AdminPasswordResetBody) + if renderErr != nil { + return renderErr + } + + return e.MailClient.Send( + mail.Address{ + Name: app.Settings().Meta.SenderName, + Address: app.Settings().Meta.SenderAddress, + }, + mail.Address{Address: e.Admin.Email}, + "Reset admin password", + body, + nil, + ) + }) + + if sendErr == nil { + app.OnMailerAfterAdminResetPasswordSend().Trigger(event) + } + + return sendErr +} diff --git a/mails/admin_test.go b/mails/admin_test.go new file mode 100644 index 000000000..49ed865aa --- /dev/null +++ b/mails/admin_test.go @@ -0,0 +1,37 @@ +package mails_test + +import ( + "strings" + "testing" + + "github.com/pocketbase/pocketbase/mails" + "github.com/pocketbase/pocketbase/tests" +) + +func TestSendAdminPasswordReset(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + // ensure that action url normalization will be applied + testApp.Settings().Meta.AppUrl = "http://localhost:8090////" + + admin, _ := testApp.Dao().FindAdminByEmail("test@example.com") + + err := mails.SendAdminPasswordReset(testApp, admin) + if err != nil { + t.Fatal(err) + } + + if testApp.TestMailer.TotalSend != 1 { + t.Fatalf("Expected one email to be sent, got %d", testApp.TestMailer.TotalSend) + } + + expectedParts := []string{ + "http://localhost:8090/#/confirm-password-reset/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.", + } + for _, part := range expectedParts { + if !strings.Contains(testApp.TestMailer.LastHtmlBody, part) { + t.Fatalf("Couldn't find %s \nin\n %s", part, testApp.TestMailer.LastHtmlBody) + } + } +} diff --git a/mails/base.go b/mails/base.go new file mode 100644 index 000000000..156389b39 --- /dev/null +++ b/mails/base.go @@ -0,0 +1,58 @@ +// Package mails implements various helper methods for sending user and admin +// emails like forgotten password, verification, etc. +package mails + +import ( + "bytes" + "net/url" + "path" + "strings" + "text/template" +) + +// normalizeUrl removes duplicated slashes from a url path. +func normalizeUrl(originalUrl string) (string, error) { + u, err := url.Parse(originalUrl) + if err != nil { + return "", err + } + + hasSlash := strings.HasSuffix(u.Path, "/") + + // clean up path by removing duplicated / + u.Path = path.Clean(u.Path) + u.RawPath = path.Clean(u.RawPath) + + // restore original trailing slash + if hasSlash && !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + u.RawPath += "/" + } + + return u.String(), nil +} + +// resolveTemplateContent resolves inline html template strings. +func resolveTemplateContent(data any, content ...string) (string, error) { + if len(content) == 0 { + return "", nil + } + + t := template.New("inline_template") + + var parseErr error + for _, v := range content { + t, parseErr = t.Parse(v) + if parseErr != nil { + return "", parseErr + } + } + + var wr bytes.Buffer + + if executeErr := t.Execute(&wr, data); executeErr != nil { + return "", executeErr + } + + return wr.String(), nil +} diff --git a/mails/templates/admin_password_reset.go b/mails/templates/admin_password_reset.go new file mode 100644 index 000000000..e0d760583 --- /dev/null +++ b/mails/templates/admin_password_reset.go @@ -0,0 +1,25 @@ +package templates + +// Available variables: +// +// ``` +// Admin *models.Admin +// AppName string +// AppUrl string +// Token string +// ActionUrl string +// ``` +const AdminPasswordResetBody = ` +{{define "content"}} +Hello,
+ +Follow this link to reset your admin password for {{.AppName}}.
+ ++ Reset password + {{.ActionUrl}} +
+ +If you did not request to reset your password, please ignore this email and the link will expire on its own.
+{{end}} +` diff --git a/mails/templates/html_content.go b/mails/templates/html_content.go new file mode 100644 index 000000000..cb4127514 --- /dev/null +++ b/mails/templates/html_content.go @@ -0,0 +1,8 @@ +package templates + +// Available variables: +// +// ``` +// HtmlContent template.HTML +// ``` +const HtmlBody = `{{define "content"}}{{.HtmlContent}}{{end}}` diff --git a/mails/templates/layout.go b/mails/templates/layout.go new file mode 100644 index 000000000..cbc657273 --- /dev/null +++ b/mails/templates/layout.go @@ -0,0 +1,117 @@ +package templates + +const Layout = ` + + + + + + + + +Hello,
+Click on the button below to confirm your new email address.
++ Confirm new email + {{.ActionUrl}} +
+If you didn’t ask to change your email address, you can ignore this email.
+
+ Thanks,
+ {{.AppName}} team
+
Hello,
+Click on the button below to reset your password.
++ Reset password + {{.ActionUrl}} +
+If you didn’t ask to reset your password, you can ignore this email.
+
+ Thanks,
+ {{.AppName}} team
+
Hello,
+Thank you for joining us at {{.AppName}}.
+Click on the button below to verify your email address.
++ Verify + {{.ActionUrl}} +
+
+ Thanks,
+ {{.AppName}} team
+
Something went wrong
Lorem ipsum dolor sit amet
Enter the email associated with your account and we\u2019ll send you a recovery link:
`,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='',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}; diff --git a/ui/dist/assets/PageUserConfirmEmailChange.172a5083.js b/ui/dist/assets/PageUserConfirmEmailChange.172a5083.js new file mode 100644 index 000000000..e7e8b588b --- /dev/null +++ b/ui/dist/assets/PageUserConfirmEmailChange.172a5083.js @@ -0,0 +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.944ee0db.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=`Email address changed
+You can now sign in with your new email address.
Password changed
+You can now sign in with your new password.
Invalid or expired verification token.
Successfully verified email address.