1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-02-09 12:14:03 +02:00

initial public commit

This commit is contained in:
Gani Georgiev 2022-07-07 00:19:05 +03:00
commit 3d07f0211d
484 changed files with 92412 additions and 0 deletions

13
.github/FUNDING.yaml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}
}

View 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
}

View 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)
}
}
}

View 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)
}

View 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
View 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
View 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
View 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)
}

View 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))
}
}
}

View 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)),
)
}

View 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
View 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
View 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
View 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)
}

View 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))
}
}
}

View 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
}

View 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)
}
}
}

View 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)
}

View 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
View 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
}

View 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
View 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
}

View 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

View 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
}

View 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)
}
}
}

View 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)
}

View 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
View 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
View 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)
}
}
}

View 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
}

View 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)
}
}
}

View 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)
}

View 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
View 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, ","),
))
}
}

View 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)
}
}
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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
}
}

View 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)
}
}
}

View File

@ -0,0 +1,2 @@
// Package validators implements custom shared PocketBase validators.
package validators

87
go.mod Normal file
View 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
)

1151
go.sum Normal file

File diff suppressed because it is too large Load Diff

76
mails/admin.go Normal file
View 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