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