You've already forked pocketbase
							
							
				mirror of
				https://github.com/pocketbase/pocketbase.git
				synced 2025-10-31 08:37:38 +02:00 
			
		
		
		
	initial public commit
This commit is contained in:
		
							
								
								
									
										13
									
								
								.github/FUNDING.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.github/FUNDING.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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'] | ||||
							
								
								
									
										39
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								.github/workflows/release.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 }} | ||||
							
								
								
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										43
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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:' | ||||
							
								
								
									
										17
									
								
								LICENSE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								LICENSE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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. | ||||
							
								
								
									
										124
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| <p align="center"> | ||||
|     <a href="https://pocketbase.io" target="_blank" rel="noopener"> | ||||
|         <img src="https://i.imgur.com/ZfD4BHO.png" alt="PocketBase - open source backend in 1 file" /> | ||||
|     </a> | ||||
| </p> | ||||
|  | ||||
| <p align="center"> | ||||
|     <a href="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml" target="_blank" rel="noopener"><img src="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml/badge.svg" alt="build" /></a> | ||||
|     <a href="https://github.com/pocketbase/pocketbase/releases" target="_blank" rel="noopener"><img src="https://img.shields.io/github/release/pocketbase/pocketbase.svg" alt="Latest releases" /></a> | ||||
|     <a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/ganigeorgiev/fexpr?status.svg" alt="Go package documentation" /></a> | ||||
| </p> | ||||
|  | ||||
| [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. | ||||
							
								
								
									
										261
									
								
								apis/admin.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								apis/admin.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										654
									
								
								apis/admin_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										654
									
								
								apis/admin_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										131
									
								
								apis/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								apis/base.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										122
									
								
								apis/base_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								apis/base_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										185
									
								
								apis/collection.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								apis/collection.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										451
									
								
								apis/collection_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										451
									
								
								apis/collection_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										104
									
								
								apis/file.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								apis/file.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										102
									
								
								apis/file_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								apis/file_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										82
									
								
								apis/logs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								apis/logs.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										196
									
								
								apis/logs_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								apis/logs_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										277
									
								
								apis/middlewares.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								apis/middlewares.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										503
									
								
								apis/middlewares_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										503
									
								
								apis/middlewares_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										345
									
								
								apis/realtime.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								apis/realtime.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 "" | ||||
| } | ||||
							
								
								
									
										292
									
								
								apis/realtime_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								apis/realtime_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										432
									
								
								apis/record.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										432
									
								
								apis/record.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										914
									
								
								apis/record_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										914
									
								
								apis/record_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										71
									
								
								apis/settings.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								apis/settings.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										188
									
								
								apis/settings_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								apis/settings_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										444
									
								
								apis/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										444
									
								
								apis/user.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										900
									
								
								apis/user_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										900
									
								
								apis/user_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										77
									
								
								cmd/migrate.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								cmd/migrate.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										228
									
								
								cmd/serve.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								cmd/serve.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										21
									
								
								cmd/version.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								cmd/version.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										424
									
								
								core/app.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										424
									
								
								core/app.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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] | ||||
| } | ||||
							
								
								
									
										752
									
								
								core/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										752
									
								
								core/base.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										438
									
								
								core/base_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										438
									
								
								core/base_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										26
									
								
								core/db_cgo.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								core/db_cgo.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										16
									
								
								core/db_nocgo.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								core/db_nocgo.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||
| } | ||||
							
								
								
									
										230
									
								
								core/events.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								core/events.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										412
									
								
								core/settings.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										412
									
								
								core/settings.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||
| 		), | ||||
| 	) | ||||
| } | ||||
							
								
								
									
										606
									
								
								core/settings_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										606
									
								
								core/settings_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										124
									
								
								daos/admin.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								daos/admin.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										238
									
								
								daos/admin_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								daos/admin_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										217
									
								
								daos/base.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								daos/base.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										245
									
								
								daos/base_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										245
									
								
								daos/base_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										163
									
								
								daos/collection.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								daos/collection.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										253
									
								
								daos/collection_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								daos/collection_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										75
									
								
								daos/param.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								daos/param.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										150
									
								
								daos/param_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								daos/param_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										351
									
								
								daos/record.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								daos/record.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										155
									
								
								daos/record_expand.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								daos/record_expand.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										258
									
								
								daos/record_expand_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								daos/record_expand_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										473
									
								
								daos/record_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										473
									
								
								daos/record_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										70
									
								
								daos/request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								daos/request.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										148
									
								
								daos/request_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								daos/request_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										37
									
								
								daos/table.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								daos/table.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										81
									
								
								daos/table_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								daos/table_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										281
									
								
								daos/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										281
									
								
								daos/user.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										274
									
								
								daos/user_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										274
									
								
								daos/user_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										26
									
								
								examples/base/main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								examples/base/main.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										50
									
								
								forms/admin_login.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								forms/admin_login.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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.") | ||||
| } | ||||
							
								
								
									
										80
									
								
								forms/admin_login_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								forms/admin_login_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										76
									
								
								forms/admin_password_reset_confirm.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								forms/admin_password_reset_confirm.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										120
									
								
								forms/admin_password_reset_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								forms/admin_password_reset_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										70
									
								
								forms/admin_password_reset_request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								forms/admin_password_reset_request.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										84
									
								
								forms/admin_password_reset_request_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								forms/admin_password_reset_request_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										91
									
								
								forms/admin_upsert.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								forms/admin_upsert.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										285
									
								
								forms/admin_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										285
									
								
								forms/admin_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										215
									
								
								forms/collection_upsert.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								forms/collection_upsert.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										452
									
								
								forms/collection_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										452
									
								
								forms/collection_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										23
									
								
								forms/realtime_subscribe.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								forms/realtime_subscribe.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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)), | ||||
| 	) | ||||
| } | ||||
							
								
								
									
										31
									
								
								forms/realtime_subscribe_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								forms/realtime_subscribe_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										368
									
								
								forms/record_upsert.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										368
									
								
								forms/record_upsert.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										498
									
								
								forms/record_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										498
									
								
								forms/record_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										59
									
								
								forms/settings_upsert.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								forms/settings_upsert.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										130
									
								
								forms/settings_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								forms/settings_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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)) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										113
									
								
								forms/user_email_change_confirm.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								forms/user_email_change_confirm.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										121
									
								
								forms/user_email_change_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								forms/user_email_change_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										57
									
								
								forms/user_email_change_request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								forms/user_email_change_request.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										87
									
								
								forms/user_email_change_request_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								forms/user_email_change_request_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										52
									
								
								forms/user_email_login.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								forms/user_email_login.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										106
									
								
								forms/user_email_login_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								forms/user_email_login_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										133
									
								
								forms/user_oauth2_login.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								forms/user_oauth2_login.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										75
									
								
								forms/user_oauth2_login_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								forms/user_oauth2_login_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										78
									
								
								forms/user_password_reset_confirm.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								forms/user_password_reset_confirm.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										165
									
								
								forms/user_password_reset_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								forms/user_password_reset_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										70
									
								
								forms/user_password_reset_request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								forms/user_password_reset_request.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										153
									
								
								forms/user_password_reset_request_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								forms/user_password_reset_request_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										118
									
								
								forms/user_upsert.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								forms/user_upsert.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										242
									
								
								forms/user_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								forms/user_upsert_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										73
									
								
								forms/user_verification_confirm.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								forms/user_verification_confirm.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										140
									
								
								forms/user_verification_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								forms/user_verification_confirm_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										74
									
								
								forms/user_verification_request.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								forms/user_verification_request.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
							
								
								
									
										171
									
								
								forms/user_verification_request_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								forms/user_verification_request_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										63
									
								
								forms/validators/file.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								forms/validators/file.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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, ","), | ||||
| 		)) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										92
									
								
								forms/validators/file_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								forms/validators/file_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										418
									
								
								forms/validators/record_data.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										418
									
								
								forms/validators/record_data.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										1443
									
								
								forms/validators/record_data_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1443
									
								
								forms/validators/record_data_test.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										21
									
								
								forms/validators/string.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								forms/validators/string.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										30
									
								
								forms/validators/string_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								forms/validators/string_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										2
									
								
								forms/validators/validators.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								forms/validators/validators.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| // Package validators implements custom shared PocketBase validators. | ||||
| package validators | ||||
							
								
								
									
										87
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| ) | ||||
							
								
								
									
										76
									
								
								mails/admin.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								mails/admin.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user