mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcaacd0d28 | ||
|
|
21d7cff43d | ||
|
|
2aec49a1f1 | ||
|
|
17b7ee5200 | ||
|
|
2b5f00b62e | ||
|
|
8b93aac8e3 | ||
|
|
eb51cdf1aa | ||
|
|
647997517f | ||
|
|
0e1f6b69d0 | ||
|
|
6718f4469b | ||
|
|
c27349905c | ||
|
|
1b48bdb81a | ||
|
|
bb801e7de0 | ||
|
|
adf902cae7 | ||
|
|
d2a617848d | ||
|
|
7ae2a7e846 | ||
|
|
448120bf18 | ||
|
|
667bcac680 | ||
|
|
64fd347628 | ||
|
|
0340d8add8 | ||
|
|
2c9e8995f9 | ||
|
|
f01f1df07a | ||
|
|
f4533f3d26 | ||
|
|
7a47a8a979 | ||
|
|
deccb3dbdb | ||
|
|
c51148e4d7 | ||
|
|
51ee1b5367 | ||
|
|
b8d7609e9e | ||
|
|
32af49dbec | ||
|
|
cf9e2a33bb | ||
|
|
a27298d1ef | ||
|
|
536707bfe7 | ||
|
|
2128b15541 | ||
|
|
eb1246fc41 | ||
|
|
ae371e8481 | ||
|
|
39df263a03 | ||
|
|
b3fa1f0fea | ||
|
|
ecfae2e5c9 | ||
|
|
e5477961ad | ||
|
|
2d40487b21 | ||
|
|
8564a69a94 | ||
|
|
3f58908734 | ||
|
|
4c010847e3 | ||
|
|
4c73e16f54 | ||
|
|
2a34eca07a | ||
|
|
a74d227418 | ||
|
|
7001a22d92 | ||
|
|
a7e3f08df0 | ||
|
|
15583ba718 | ||
|
|
e25c252fc2 | ||
|
|
a15b192a42 | ||
|
|
2a4b3315c6 | ||
|
|
6d08a5f36f | ||
|
|
fd97732d4d | ||
|
|
36ab3fd162 | ||
|
|
8f6f87902a | ||
|
|
55c6bed57f | ||
|
|
ba7c8e2108 | ||
|
|
c1921aeef8 | ||
|
|
012546e838 | ||
|
|
f792a9e08d | ||
|
|
5fb1e85372 | ||
|
|
a5ceee33df | ||
|
|
59e4939e1d | ||
|
|
1f46b30895 | ||
|
|
a8b2f0f6f1 | ||
|
|
d37bf6452c | ||
|
|
c26ac2d53f | ||
|
|
e1c751a7e7 | ||
|
|
f7d4722052 | ||
|
|
7459c9208f | ||
|
|
c1ff1c6155 | ||
|
|
3dc1bf6fa7 | ||
|
|
d3cc87abee | ||
|
|
19ad827302 | ||
|
|
40830b6c43 | ||
|
|
f234132629 | ||
|
|
9b880f5ab4 | ||
|
|
d5775ff657 | ||
|
|
41ba7e12e1 | ||
|
|
5ef6b3a8be | ||
|
|
c673d9d314 | ||
|
|
a7aa3da67e | ||
|
|
0439af458f | ||
|
|
6f3241399c | ||
|
|
19ccc70fe5 | ||
|
|
9033cd109e | ||
|
|
e1773eead0 | ||
|
|
f6fff85d07 | ||
|
|
166d2eafc7 | ||
|
|
d710446c71 | ||
|
|
8f6cb5ad2d | ||
|
|
775417ac2b | ||
|
|
079616ee8e | ||
|
|
26b794aa08 | ||
|
|
d55610511d | ||
|
|
a00606d969 | ||
|
|
4abc8ae021 | ||
|
|
f91f009fce | ||
|
|
7fc1d979dd | ||
|
|
ede7804a80 | ||
|
|
cea287a2c1 | ||
|
|
9e03ac4dc4 | ||
|
|
e713de1e44 | ||
|
|
233ab62f8e | ||
|
|
3449084e54 | ||
|
|
15c05b9679 | ||
|
|
fb57c8091d | ||
|
|
ca528cef03 | ||
|
|
4ceab4e7ed | ||
|
|
e8fa51526a | ||
|
|
a43713ce14 | ||
|
|
7d7d20744e | ||
|
|
bd16680548 | ||
|
|
84ba89d5af | ||
|
|
bf5b1db672 | ||
|
|
71d3f8f4c7 | ||
|
|
687a79b450 | ||
|
|
738f71f244 | ||
|
|
64f3c5a604 | ||
|
|
a8c996c93d | ||
|
|
9ae8536515 | ||
|
|
5f6b7f6cc0 | ||
|
|
89de29fc84 | ||
|
|
c25e67e13d | ||
|
|
8e582acbee | ||
|
|
b9e257d2b1 | ||
|
|
e964b019c2 | ||
|
|
4cbb7f58cd | ||
|
|
8815f6060c | ||
|
|
5183280c39 | ||
|
|
1f45b858a6 | ||
|
|
6d46cefd1f | ||
|
|
b1a63bb98d | ||
|
|
1248421703 | ||
|
|
be3dd42eac | ||
|
|
0eeae9de80 | ||
|
|
55b439cb1c | ||
|
|
0696a252cc | ||
|
|
54c52f696c | ||
|
|
21f442293f | ||
|
|
18d6a1c529 | ||
|
|
ad321c01e0 | ||
|
|
29c99319dc | ||
|
|
18285e7505 | ||
|
|
846b56d393 | ||
|
|
f30c9f263f | ||
|
|
b63268559f | ||
|
|
5c899a4cf0 | ||
|
|
506bfca8b2 | ||
|
|
007fcd36b8 | ||
|
|
707f35f461 | ||
|
|
972b06c708 | ||
|
|
37bac5cc50 | ||
|
|
aa6eaa7319 | ||
|
|
68a9782c03 | ||
|
|
869d1cbcf7 | ||
|
|
451611776e | ||
|
|
9c7f48a66e | ||
|
|
59b41c8202 | ||
|
|
94658712c6 | ||
|
|
2c4ac070a3 | ||
|
|
e206e303ca | ||
|
|
e60f470188 | ||
|
|
9cf5e28700 | ||
|
|
7aefcd9bf6 | ||
|
|
693954cdcd | ||
|
|
355f7053fd | ||
|
|
808f5054d0 | ||
|
|
f1d546c845 | ||
|
|
dba66d4da1 | ||
|
|
4070a11660 | ||
|
|
45b72dd6b3 | ||
|
|
599c542c5a | ||
|
|
b8cd686b32 | ||
|
|
604009bd10 | ||
|
|
02f72638b8 | ||
|
|
23fbfab63a | ||
|
|
98cc8e2aee | ||
|
|
04018f931b | ||
|
|
d2028143df | ||
|
|
6400924d29 | ||
|
|
44a69eb4ba | ||
|
|
0fa5edb0b1 | ||
|
|
799e1d96f8 | ||
|
|
9ba710cdc5 | ||
|
|
a4953cbb4e | ||
|
|
647158f62d | ||
|
|
2deca759fa | ||
|
|
bd65125744 | ||
|
|
328b99a690 | ||
|
|
33539452de | ||
|
|
c6f03cda43 | ||
|
|
2d3531dd66 | ||
|
|
7ac3a74440 | ||
|
|
3bce173748 | ||
|
|
b024737ec8 | ||
|
|
675d459137 | ||
|
|
8c9b657132 | ||
|
|
d8963c6fc3 | ||
|
|
3e1a19685b | ||
|
|
c4a660d2d2 | ||
|
|
c12c873a65 | ||
|
|
550260b381 | ||
|
|
b9922e4843 | ||
|
|
3c3a61c457 | ||
|
|
aed8367231 | ||
|
|
341bcc4a0e | ||
|
|
3b9a9df171 | ||
|
|
a230cc1719 | ||
|
|
7dee9d5cc4 | ||
|
|
0b54d1736e | ||
|
|
39408f135b | ||
|
|
6e9cf986c5 | ||
|
|
4528f075dc | ||
|
|
f3566149b8 | ||
|
|
9322b13d15 | ||
|
|
77d295730e | ||
|
|
a998618d75 | ||
|
|
4c096fd745 | ||
|
|
c95e50c8a5 | ||
|
|
bac5d76725 | ||
|
|
50fce1f3cf | ||
|
|
521df149a2 | ||
|
|
39dc1d2795 | ||
|
|
3d14addfef | ||
|
|
bc519231d9 | ||
|
|
b1c7a012c5 | ||
|
|
01814067b1 | ||
|
|
7225b380d5 | ||
|
|
fa791b7e96 | ||
|
|
0ff5606d80 | ||
|
|
4cddb6b5cb | ||
|
|
46dc6cc47c | ||
|
|
65693d1916 | ||
|
|
a2abeb872a | ||
|
|
6115fb3874 | ||
|
|
cb6ffc1e7b | ||
|
|
152f6a9d1f | ||
|
|
fe656a1c42 | ||
|
|
7e7e2e98a4 | ||
|
|
099230a552 | ||
|
|
5e0718176d | ||
|
|
1a28532546 | ||
|
|
8bb03d2e6b | ||
|
|
639522b142 | ||
|
|
9cef6ebd82 | ||
|
|
5298543ce4 | ||
|
|
bcb9c22685 | ||
|
|
90dba45d7c | ||
|
|
9cbb2e750e | ||
|
|
6385c5e646 |
@@ -0,0 +1,5 @@
|
||||
# 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.
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.19.0'
|
||||
go-version: '>=1.20.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.
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@
|
||||
# goreleaser builds folder
|
||||
/.builds/
|
||||
|
||||
# examples app directories
|
||||
pb_data
|
||||
pb_public
|
||||
|
||||
# tests coverage
|
||||
coverage.out
|
||||
|
||||
# plaintask todo files
|
||||
*.todo
|
||||
|
||||
# generated markdown previews
|
||||
README.html
|
||||
CHANGELOG.html
|
||||
LICENSE.html
|
||||
|
||||
+1136
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -26,7 +26,7 @@ git clone https://github.com/your_username/pocketbase.git
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
PocketBase is a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
|
||||
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022, Gani Georgiev
|
||||
Copyright (c) 2022 - present, 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,
|
||||
|
||||
@@ -2,8 +2,8 @@ lint:
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
test:
|
||||
go test -v --cover ./...
|
||||
go test ./... -v --cover
|
||||
|
||||
test-report:
|
||||
go test -v --cover -coverprofile=coverage.out ./...
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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" />
|
||||
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Although the web API definitions are considered stable,
|
||||
> please keep in mind that PocketBase is still under active development
|
||||
> ⚠️ Please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
|
||||
@@ -64,15 +63,15 @@ func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// add new "GET /api/hello" route to the app router (echo)
|
||||
// add new "GET /hello" route to the app router (echo)
|
||||
e.Router.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/hello",
|
||||
Path: "/hello",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "Hello world!")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.ActivityLogger(app),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,11 +90,33 @@ Running/building the application is the same as for any other Go program, aka. j
|
||||
|
||||
**PocketBase embeds SQLite, but doesn't require CGO.**
|
||||
|
||||
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
|
||||
|
||||
If CGO is enabled (aka. `CGO_ENABLED=1`), 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.
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory.
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
4. Start the created executable by running `./base serve`.
|
||||
|
||||
The supported build targets by the non-cgo driver at the moment are:
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
freebsd amd64
|
||||
freebsd arm64
|
||||
linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -111,22 +132,28 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
|
||||
|
||||
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.
|
||||
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 are free to do whatever you want with it, even offering it as a paid service.
|
||||
|
||||
You could help continuing its development by:
|
||||
|
||||
- [Contribute to the source code](CONTRIBUTING.md)
|
||||
|
||||
> **NB! PR for new features will not be merged until we manage to complete the users refactoring in [#376](https://github.com/pocketbase/pocketbase/issues/376)!**
|
||||
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
> Please note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3.
|
||||
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
|
||||
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
|
||||
|
||||
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
|
||||
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
|
||||
|
||||
_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._
|
||||
|
||||
+157
-77
@@ -9,20 +9,19 @@ import (
|
||||
"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) {
|
||||
// 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("/auth-with-password", api.authWithPassword)
|
||||
subGroup.POST("/request-password-reset", api.requestPasswordReset)
|
||||
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
|
||||
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
|
||||
subGroup.GET("/:id", api.view, RequireAdminAuth())
|
||||
@@ -37,14 +36,13 @@ type adminApi struct {
|
||||
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)
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := &core.AdminAuthEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
Token: token,
|
||||
}
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
@@ -54,62 +52,139 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (api *adminApi) refresh(c echo.Context) error {
|
||||
func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
return rest.NewNotFoundError("Missing auth admin context.", nil)
|
||||
return NewNotFoundError("Missing auth admin context.", nil)
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
event := new(core.AdminAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) emailAuth(c echo.Context) error {
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
form := forms.NewAdminLogin(api.app)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
|
||||
event := new(core.AdminAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
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 occurred while loading the submitted data.", err)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while validating the form.", err)
|
||||
return NewBadRequestError("An error occurred 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)
|
||||
event := new(core.AdminRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(Admin *models.Admin) error {
|
||||
event.Admin = Admin
|
||||
|
||||
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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 occurred while loading the submitted data.", readErr)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
admin, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to set new password.", submitErr)
|
||||
event := new(core.AdminConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return api.authResponse(c, admin)
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) list(c echo.Context) error {
|
||||
@@ -121,17 +196,16 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().AdminQuery()).
|
||||
ParseAndExec(c.QueryString(), &admins)
|
||||
ParseAndExec(c.QueryParams().Encode(), &admins)
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminsListEvent{
|
||||
HttpContext: c,
|
||||
Admins: admins,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.AdminsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Admins = admins
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -141,18 +215,17 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
func (api *adminApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminViewEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -166,20 +239,21 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminCreateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// create the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create admin.", err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -188,7 +262,9 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -197,32 +273,33 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
func (api *adminApi) update(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(api.app, admin)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.AdminUpdateEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
// update the admin
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
event.Admin = m
|
||||
|
||||
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update admin.", err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
@@ -231,7 +308,9 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnAdminAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -240,29 +319,30 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
func (api *adminApi) delete(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
admin, err := api.app.Dao().FindAdminById(id)
|
||||
if err != nil || admin == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.AdminDeleteEvent{
|
||||
HttpContext: c,
|
||||
Admin: admin,
|
||||
}
|
||||
event := new(core.AdminDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.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 NewBadRequestError("Failed to delete admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnAdminAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
|
||||
+219
-138
@@ -14,55 +14,79 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestAdminAuth(t *testing.T) {
|
||||
func TestAdminAuthWithPassword(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(``),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
ExpectedContent: []string{`"data":{"identity":{"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",
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "wrong email/password",
|
||||
Name: "wrong email",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
Name: "wrong password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (guest)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-via-email",
|
||||
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email/password (already authorized)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -106,10 +130,12 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnMailerBeforeAdminResetPasswordSend": 1,
|
||||
"OnMailerAfterAdminResetPasswordSend": 1,
|
||||
"OnAdminBeforeRequestPasswordResetRequest": 1,
|
||||
"OnAdminAfterRequestPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -158,27 +184,44 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
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"}`),
|
||||
Name: "expired token",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
|
||||
"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":`,
|
||||
},
|
||||
Name: "valid token + invalid password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"123456",
|
||||
"passwordConfirm":"123456"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
|
||||
},
|
||||
{
|
||||
Name: "valid token + valid password",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -193,34 +236,46 @@ func TestAdminRefresh(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Name: "authorized as admin (expired token)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/refresh",
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid token)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"admin":{"id":"sywbhecnh46rhm0"`,
|
||||
`"token":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminAuthRequest": 1,
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -244,7 +299,7 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -254,16 +309,17 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":2`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sywbhecnh46rhm0"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"id":"9q2trqumvlyr3bd"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -274,15 +330,19 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?page=2&perPage=1&sort=-created",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":1`,
|
||||
`"totalItems":2`,
|
||||
`"totalItems":3`,
|
||||
`"items":[{`,
|
||||
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -293,7 +353,7 @@ func TestAdminsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?filter=invalidfield~'test2'",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -301,9 +361,9 @@ func TestAdminsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + valid filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins?filter=email~'test2'",
|
||||
Url: "/api/admins?filter=email~'test3'",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -311,7 +371,11 @@ func TestAdminsList(t *testing.T) {
|
||||
`"perPage":30`,
|
||||
`"totalItems":1`,
|
||||
`"items":[{`,
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"9q2trqumvlyr3bd"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminsListRequest": 1,
|
||||
@@ -329,36 +393,26 @@ func TestAdminView(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
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",
|
||||
Url: "/api/admins/nonexisting",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -366,13 +420,17 @@ func TestAdminView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + existing admin id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminViewRequest": 1,
|
||||
@@ -390,36 +448,26 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid admin id",
|
||||
Name: "authorized as admin + missing admin id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/invalid",
|
||||
Url: "/api/admins/missing",
|
||||
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",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -427,9 +475,9 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + existing admin id",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -442,15 +490,15 @@ func TestAdminDelete(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin - try to delete the only remaining admin",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
Url: "/api/admins/sywbhecnh46rhm0",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: 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",
|
||||
"id": "sywbhecnh46rhm0",
|
||||
})).Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -508,7 +556,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -519,7 +567,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
|
||||
@@ -530,7 +578,7 @@ func TestAdminCreate(t *testing.T) {
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -539,20 +587,36 @@ func TestAdminCreate(t *testing.T) {
|
||||
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}`),
|
||||
Body: strings.NewReader(`{
|
||||
"email":"test@example.com",
|
||||
"password":"1234",
|
||||
"passwordConfirm":"4321",
|
||||
"avatar":99
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"avatar":{"code":"validation_max_less_equal_than_required"`,
|
||||
`"email":{"code":"validation_admin_email_exists"`,
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
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."}}`},
|
||||
},
|
||||
{
|
||||
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}`),
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890",
|
||||
"avatar":3
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -560,6 +624,12 @@ func TestAdminCreate(t *testing.T) {
|
||||
`"email":"testnew@example.com"`,
|
||||
`"avatar":3`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"password"`,
|
||||
`"passwordConfirm"`,
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
@@ -579,38 +649,27 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid admin id",
|
||||
Name: "authorized as admin + missing admin",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/invalid",
|
||||
Url: "/api/admins/missing",
|
||||
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",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -618,14 +677,14 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + empty data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"avatar":2`,
|
||||
},
|
||||
@@ -639,10 +698,10 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + invalid formatted data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -650,27 +709,49 @@ func TestAdminUpdate(t *testing.T) {
|
||||
{
|
||||
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}`),
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"test@example.com",
|
||||
"password":"1234",
|
||||
"passwordConfirm":"4321",
|
||||
"avatar":99
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"avatar":{"code":"validation_max_less_equal_than_required"`,
|
||||
`"email":{"code":"validation_admin_email_exists"`,
|
||||
`"password":{"code":"validation_length_out_of_range"`,
|
||||
`"passwordConfirm":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
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."}}`},
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
|
||||
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891",
|
||||
"avatar":5
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
|
||||
`"id":"sbmbsdb40jyxf7h"`,
|
||||
`"email":"testnew@example.com"`,
|
||||
`"avatar":5`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"password"`,
|
||||
`"passwordConfirm"`,
|
||||
`"tokenKey"`,
|
||||
`"passwordHash"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package rest
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
)
|
||||
|
||||
// ApiError defines the properties for a basic api error response.
|
||||
// ApiError defines the struct for a basic api error response.
|
||||
type ApiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -23,6 +23,7 @@ func (e *ApiError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RawData returns the unformatted error data (could be an internal error, text, etc.)
|
||||
func (e *ApiError) RawData() any {
|
||||
return e.rawData
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package rest_test
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
"rawData_test",
|
||||
@@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
@@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewNotFoundError(scenario.message, scenario.data)
|
||||
e := apis.NewNotFoundError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewBadRequestError(scenario.message, scenario.data)
|
||||
e := apis.NewBadRequestError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewForbiddenError(scenario.message, scenario.data)
|
||||
e := apis.NewForbiddenError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
e := apis.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
+54
-30
@@ -2,6 +2,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
"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"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -26,6 +26,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
})
|
||||
})
|
||||
|
||||
// default middlewares
|
||||
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
@@ -43,7 +50,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return
|
||||
}
|
||||
|
||||
var apiErr *rest.ApiError
|
||||
var apiErr *ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
@@ -51,8 +58,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = rest.NewApiError(v.Code, msg, v)
|
||||
case *rest.ApiError:
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
case *ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
}
|
||||
@@ -61,22 +68,29 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
if err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
apiErr = rest.NewBadRequestError("", err)
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// Send response
|
||||
var cErr error
|
||||
if c.Request().Method == http.MethodHead {
|
||||
event := new(core.ApiErrorEvent)
|
||||
event.HttpContext = c
|
||||
event.Error = apiErr
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
cErr = c.NoContent(apiErr.Code)
|
||||
} else {
|
||||
cErr = c.JSON(apiErr.Code, apiErr)
|
||||
}
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if cErr != nil && app.IsDebug() {
|
||||
log.Println(cErr)
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
@@ -84,14 +98,15 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// 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)
|
||||
bindSettingsApi(app, api)
|
||||
bindAdminApi(app, api)
|
||||
bindCollectionApi(app, api)
|
||||
bindRecordCrudApi(app, api)
|
||||
bindRecordAuthApi(app, api)
|
||||
bindFileApi(app, api)
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
@@ -114,22 +129,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
|
||||
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
|
||||
//
|
||||
// If a file resource is missing and indexFallback is set, the request
|
||||
// will be forwarded to the base index.html (useful also for SPA).
|
||||
//
|
||||
// @see https://github.com/labstack/echo/issues/2211
|
||||
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc {
|
||||
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback 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
|
||||
|
||||
// escape url path
|
||||
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)
|
||||
fileErr := c.FileFS(name, fileSystem)
|
||||
|
||||
if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
|
||||
return c.FileFS("index.html", fileSystem)
|
||||
}
|
||||
|
||||
return fileErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
)
|
||||
|
||||
func Test404(t *testing.T) {
|
||||
@@ -66,6 +66,22 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "custom route with url encoded parameter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/a%2Bb%2Bc",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:param",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, c.PathParam("param"))
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"a+b+c"},
|
||||
},
|
||||
{
|
||||
Name: "route with HTTPError",
|
||||
Method: http.MethodGet,
|
||||
@@ -91,7 +107,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Path: "/api-error",
|
||||
Handler: func(c echo.Context) error {
|
||||
return rest.NewApiError(500, "test message", errors.New("internal_test"))
|
||||
return apis.NewApiError(500, "test message", errors.New("internal_test"))
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
+62
-56
@@ -1,18 +1,18 @@
|
||||
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/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) {
|
||||
// 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())
|
||||
@@ -30,24 +30,23 @@ type collectionApi struct {
|
||||
|
||||
func (api *collectionApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(
|
||||
"id", "created", "updated", "name", "system",
|
||||
"id", "created", "updated", "name", "system", "type",
|
||||
)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.Dao().CollectionQuery()).
|
||||
ParseAndExec(c.QueryString(), &collections)
|
||||
ParseAndExec(c.QueryParams().Encode(), &collections)
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionsListEvent{
|
||||
HttpContext: c,
|
||||
Collections: collections,
|
||||
Result: result,
|
||||
}
|
||||
event := new(core.CollectionsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = collections
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -57,13 +56,12 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
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)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionViewEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -77,20 +75,21 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionCreateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create the collection.", err)
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -99,7 +98,9 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterCreateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -108,27 +109,28 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
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)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(api.app, collection)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionUpdateEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update the collection.", err)
|
||||
if err := next(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
@@ -137,7 +139,9 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -146,24 +150,25 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
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)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionDeleteEvent{
|
||||
HttpContext: c,
|
||||
Collection: collection,
|
||||
}
|
||||
event := new(core.CollectionDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.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)
|
||||
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnCollectionAfterDeleteRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
@@ -174,22 +179,21 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
|
||||
// load request data
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.CollectionsImportEvent{
|
||||
HttpContext: c,
|
||||
Collections: form.Collections,
|
||||
}
|
||||
event := new(core.CollectionsImportEvent)
|
||||
event.HttpContext = c
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
form.Collections = e.Collections // ensures that the form always has the latest changes
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if err := next(e.Collections); err != nil {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
@@ -198,7 +202,9 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnCollectionsAfterImportRequest().Trigger(event)
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
|
||||
+539
-136
File diff suppressed because it is too large
Load Diff
+21
-21
@@ -6,14 +6,13 @@ import (
|
||||
"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 imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// BindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func BindFileApi(app core.App, rg *echo.Group) {
|
||||
// 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))
|
||||
@@ -27,30 +26,30 @@ type fileApi struct {
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
recordId := c.PathParam("recordId")
|
||||
if recordId == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
filename := c.PathParam("filename")
|
||||
|
||||
fileField := record.FindFileFieldByFile(filename)
|
||||
if fileField == nil {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
options, _ := fileField.Options.(*schema.FileOptions)
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Filesystem initialization failure.", err)
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
// extract the original file meta attributes and check it existence
|
||||
oAttrs, oAttrsErr := fs.Attributes(originalPath)
|
||||
if oAttrsErr != nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
// check if it is an image
|
||||
@@ -85,18 +84,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
event := &core.FileDownloadEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
Collection: collection,
|
||||
FileField: fileField,
|
||||
ServedPath: servedPath,
|
||||
ServedName: servedName,
|
||||
}
|
||||
event := new(core.FileDownloadEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = servedPath
|
||||
event.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)
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+21
-20
@@ -14,14 +14,15 @@ import (
|
||||
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")
|
||||
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png")
|
||||
|
||||
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
|
||||
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
|
||||
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
|
||||
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
|
||||
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
|
||||
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
|
||||
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png")
|
||||
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png")
|
||||
|
||||
testFile, fileErr := os.ReadFile(testFilePath)
|
||||
if fileErr != nil {
|
||||
@@ -67,28 +68,28 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "missing collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
|
||||
Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.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",
|
||||
Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.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",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.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",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -98,7 +99,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
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",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -108,7 +109,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropCenter)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -118,7 +119,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50t",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropTop)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -128,7 +129,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50b",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropBottom)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -138,7 +139,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50f",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbFit)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -148,7 +149,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=0x50",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroWidth)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -158,7 +159,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x0",
|
||||
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroHeight)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -168,7 +169,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
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",
|
||||
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// bindHealthApi registers the health api endpoint.
|
||||
func bindHealthApi(app core.App, rg *echo.Group) {
|
||||
api := healthApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/health")
|
||||
subGroup.GET("", api.healthCheck)
|
||||
}
|
||||
|
||||
type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -7,12 +7,11 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"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) {
|
||||
// bindLogsApi registers the request logs api endpoints.
|
||||
func bindLogsApi(app core.App, rg *echo.Group) {
|
||||
api := logsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/logs", RequireAdminAuth())
|
||||
@@ -36,10 +35,10 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryString(), &[]*models.Request{})
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
var err error
|
||||
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Invalid filter format.", err)
|
||||
return 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 NewBadRequestError("Failed to generate requests stats.", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
func (api *logsApi) requestView(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
request, err := api.app.LogsDao().FindRequestById(id)
|
||||
if err != nil || request == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, request)
|
||||
|
||||
+14
-14
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -32,7 +32,7 @@ func TestRequestsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -54,7 +54,7 @@ func TestRequestsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests?filter=status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -101,7 +101,7 @@ func TestRequestView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -116,7 +116,7 @@ func TestRequestView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -159,7 +159,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -168,7 +168,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
|
||||
`[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -176,7 +176,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats?filter=status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
@@ -185,7 +185,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
|
||||
`[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+133
-77
@@ -11,30 +11,32 @@ import (
|
||||
"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/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"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"
|
||||
ContextAuthRecordKey string = "authRecord"
|
||||
ContextCollectionKey string = "collection"
|
||||
)
|
||||
|
||||
// RequireGuestOnly middleware requires a request to NOT have a valid
|
||||
// Authorization header set.
|
||||
// Authorization header.
|
||||
//
|
||||
// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
|
||||
// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
|
||||
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)
|
||||
err := NewBadRequestError("The request can be accessed only by guests.", nil)
|
||||
|
||||
user, _ := c.Get(ContextUserKey).(*models.User)
|
||||
if user != nil {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireUserAuth middleware requires a request to have
|
||||
// a valid user Authorization header set (aka. `Authorization: User ...`).
|
||||
func RequireUserAuth() echo.MiddlewareFunc {
|
||||
// RequireRecordAuth middleware requires a request to have
|
||||
// a valid record auth Authorization header.
|
||||
//
|
||||
// The auth record could be from any collection.
|
||||
//
|
||||
// You can further filter the allowed record auth collections by
|
||||
// specifying their names.
|
||||
//
|
||||
// Example:
|
||||
// apis.RequireRecordAuth()
|
||||
// Or:
|
||||
// apis.RequireRecordAuth("users", "supervisors")
|
||||
//
|
||||
// To restrict the auth record only to the loaded context collection,
|
||||
// use [apis.RequireSameContextRecordAuth()] instead.
|
||||
func RequireRecordAuth(optCollectionNames ...string) 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)
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
// check record collection name
|
||||
if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
||||
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// RequireSameContextRecordAuth middleware requires a request to have
|
||||
// a valid record Authorization header.
|
||||
//
|
||||
// The auth record must be from the same collection already loaded in the context.
|
||||
func RequireSameContextRecordAuth() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil || record.Collection().Id != collection.Id {
|
||||
return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -64,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminAuth middleware requires a request to have
|
||||
// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
|
||||
// a valid admin Authorization header.
|
||||
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 NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -79,14 +122,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminAuthOnlyIfAny middleware requires a request to have
|
||||
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
|
||||
// ONLY if the application has at least 1 existing Admin model.
|
||||
// a valid admin Authorization header ONLY if the application has
|
||||
// at least 1 existing Admin model.
|
||||
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to fetch admins info.", err)
|
||||
return NewBadRequestError("Failed to fetch admins info.", err)
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -95,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
|
||||
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminOrUserAuth middleware requires a request to have
|
||||
// a valid admin or user Authorization header set
|
||||
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
|
||||
// RequireAdminOrRecordAuth middleware requires a request to have
|
||||
// a valid admin or record Authorization header set.
|
||||
//
|
||||
// You can further filter the allowed auth record collections by providing their names.
|
||||
//
|
||||
// This middleware is the opposite of [apis.RequireGuestOnly()].
|
||||
func RequireAdminOrUserAuth() echo.MiddlewareFunc {
|
||||
func RequireAdminOrRecordAuth(optCollectionNames ...string) 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)
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
if admin == nil && user == nil {
|
||||
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
|
||||
if admin == nil && record == nil {
|
||||
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
|
||||
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -121,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// RequireAdminOrOwnerAuth middleware requires a request to have
|
||||
// a valid admin or user owner Authorization header set
|
||||
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
|
||||
// a valid admin or auth record owner Authorization header set.
|
||||
//
|
||||
// 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").
|
||||
// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
|
||||
// for the auth record token expects to have the same id as the path
|
||||
// parameter ownerIdParam (default to "id" if empty).
|
||||
func RequireAdminOrOwnerAuth(ownerIdParam string) 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 next(c)
|
||||
}
|
||||
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
|
||||
}
|
||||
|
||||
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)
|
||||
// note: it is "safe" to compare only the record id since the auth
|
||||
// record ids are treated as unique across all auth collections
|
||||
if record.Id != ownerId {
|
||||
return NewForbiddenError("You are not allowed to perform this request.", nil)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
@@ -152,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
// LoadAuthContext middleware reads the Authorization request header
|
||||
// and loads the token related user or admin instance into the
|
||||
// and loads the token related record or admin instance into the
|
||||
// request's context.
|
||||
//
|
||||
// This middleware is expected to be registered by default for all routes.
|
||||
// This middleware is expected to be already 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 == "" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// the schema is not required and it is only for
|
||||
// compatibility with the defaults of some HTTP clients
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
tokenType := cast.ToString(claims["type"])
|
||||
|
||||
switch tokenType {
|
||||
case tokens.TypeAdmin:
|
||||
admin, err := app.Dao().FindAdminByToken(
|
||||
token,
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
)
|
||||
if err == nil && admin != nil {
|
||||
c.Set(ContextAdminKey, admin)
|
||||
}
|
||||
case tokens.TypeAuthRecord:
|
||||
record, err := app.Dao().FindAuthRecordByToken(
|
||||
token,
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
)
|
||||
if err == nil && record != nil {
|
||||
c.Set(ContextAuthRecordKey, record)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
|
||||
|
||||
// LoadCollectionContext middleware finds the collection with related
|
||||
// path identifier and loads it into the request context.
|
||||
func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
|
||||
//
|
||||
// Set optCollectionTypes to further filter the found collection by its type.
|
||||
func LoadCollectionContext(app core.App, optCollectionTypes ...string) 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)
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
|
||||
return NewBadRequestError("Invalid collection type.", nil)
|
||||
}
|
||||
|
||||
c.Set(ContextCollectionKey, collection)
|
||||
@@ -231,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case *rest.ApiError:
|
||||
case *ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
@@ -242,8 +309,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextUserKey) != nil {
|
||||
requestAuth = models.RequestAuthUser
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
@@ -266,19 +333,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
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)
|
||||
}
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
|
||||
+418
-42
@@ -12,11 +12,11 @@ import (
|
||||
func TestRequireGuestOnly(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -38,7 +38,7 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -60,7 +60,7 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -103,7 +103,7 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireUserAuth(t *testing.T) {
|
||||
func TestRequireRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -129,7 +129,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -151,7 +151,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -169,11 +169,11 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireUserAuth(),
|
||||
apis.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection not in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireRecordAuth("demo1", "demo2"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireRecordAuth("demo1", "demo2", "users"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token but from different collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection/test",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireSameContextRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -223,7 +383,7 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -241,11 +401,11 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -267,7 +427,7 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -342,7 +502,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -360,11 +520,11 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -386,7 +546,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -410,7 +570,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -436,7 +596,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -454,11 +614,11 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token",
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection not in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid record token with collection in the restricted list",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -480,7 +684,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token + restricted collections list (should be ignored)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: 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.RequireAdminOrRecordAuth("demo1", "demo2"),
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -509,7 +735,7 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
@@ -528,9 +754,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -548,12 +774,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token (different user)",
|
||||
Name: "valid record token (different user)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
// test3@example.com
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -571,11 +796,33 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "valid user token (owner)",
|
||||
Name: "valid record token (different collection)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
BeforeTestFunc: 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 record token (owner)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -595,9 +842,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
Url: "/my/test/4q1xlclmfloku33",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCollectionContext(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/missing",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid admin token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "mismatched type",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/demo1",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app, "auth"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "matched type",
|
||||
Method: http.MethodGet,
|
||||
Url: "/my/users",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/my/:collection",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.LoadCollectionContext(app, "auth"),
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+156
-83
@@ -15,13 +15,13 @@ import (
|
||||
"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/routine"
|
||||
"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) {
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
api := realtimeApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/realtime", ActivityLogger(app))
|
||||
@@ -43,7 +43,18 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
// register new subscription client
|
||||
client := subscriptions.NewDefaultClient()
|
||||
api.app.SubscriptionsBroker().Register(client)
|
||||
defer api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
defer func() {
|
||||
disconnectEvent := &core.RealtimeDisconnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -52,24 +63,45 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
event := &core.RealtimeConnectEvent{
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(event); err != nil {
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if api.app.IsDebug() {
|
||||
log.Printf("Realtime connection establisehd: %s\n", client.Id())
|
||||
log.Printf("Realtime connection established: %s\n", client.Id())
|
||||
}
|
||||
|
||||
// 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()
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
@@ -89,11 +121,29 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
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()
|
||||
msgEvent := &core.RealtimeMessageEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
Message: &msg,
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
@@ -113,25 +163,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
|
||||
// read request data
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
// validate request data
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return 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)
|
||||
return 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)
|
||||
return NewForbiddenError("The current and the previous request authorization don't match.", nil)
|
||||
}
|
||||
|
||||
event := &core.RealtimeSubscribeEvent{
|
||||
@@ -143,7 +193,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
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))
|
||||
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
|
||||
|
||||
// unsubscribe from any previous existing subscriptions
|
||||
e.Client.Unsubscribe()
|
||||
@@ -161,53 +211,52 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
|
||||
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
|
||||
client.Set(contextKey, newModel)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
|
||||
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *realtimeApi) bindEvents() {
|
||||
userTable := (&models.User{}).TableName()
|
||||
adminTable := (&models.Admin{}).TableName()
|
||||
|
||||
// update user/admin auth state
|
||||
// update the clients that has admin or auth record association
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
default:
|
||||
return nil
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
client.Set(contextKey, e.Model)
|
||||
}
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
return api.updateClientsAuthModel(ContextAdminKey, admin)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove user/admin client(s)
|
||||
// remove the client(s) associated to the deleted admin or auth record
|
||||
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
|
||||
modelTable := e.Model.TableName()
|
||||
|
||||
var contextKey string
|
||||
switch modelTable {
|
||||
case userTable:
|
||||
contextKey = ContextUserKey
|
||||
case adminTable:
|
||||
contextKey = ContextAdminKey
|
||||
default:
|
||||
return nil
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
model, _ := client.Get(contextKey).(models.Model)
|
||||
if model != nil && model.GetId() == e.Model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -215,21 +264,27 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("create", record)
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("update", record)
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
api.broadcastRecord("delete", record)
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -253,18 +308,12 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
}
|
||||
|
||||
// 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()
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -275,7 +324,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
@@ -299,24 +348,26 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// remove the expand from the broadcasted record because we don't
|
||||
// know if the clients have access to view the expanded records
|
||||
cleanRecord := *record
|
||||
cleanRecord.SetExpand(nil)
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know if the clients have permissions to view them
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/*"): collection.ListRule,
|
||||
(collection.Id + "/*"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: &cleanRecord,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
serializedData, err := json.Marshal(data)
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
@@ -324,7 +375,11 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return err
|
||||
}
|
||||
|
||||
encodedData := string(dataBytes)
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
continue
|
||||
@@ -336,10 +391,28 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: string(serializedData),
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
// ignore the auth record email visibility checks for
|
||||
// auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == data.Record.Id ||
|
||||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
|
||||
data.Record.IgnoreEmailVisibility(true) // ignore
|
||||
if newData, err := json.Marshal(data); err == nil {
|
||||
msg.Data = string(newData)
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
}
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,9 +424,9 @@ type getter interface {
|
||||
}
|
||||
|
||||
func extractAuthIdFromGetter(val getter) string {
|
||||
user, _ := val.Get(ContextUserKey).(*models.User)
|
||||
if user != nil {
|
||||
return user.Id
|
||||
record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record != nil {
|
||||
return record.Id
|
||||
}
|
||||
|
||||
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
+81
-30
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
)
|
||||
|
||||
@@ -25,7 +27,56 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
`data:{"clientId":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
AfterTestFunc: 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()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
if e.Message.Name == "PB_CONNECT" {
|
||||
return errors.New("PB_CONNECT error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AfterTestFunc: 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()))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRealtimeBeforeMessageSend().Add(func(e *core.RealtimeMessageEvent) error {
|
||||
return hook.StopPropagation
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
@@ -46,7 +97,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
resetClient := func() {
|
||||
client.Unsubscribe()
|
||||
client.Set(apis.ContextAdminKey, nil)
|
||||
client.Set(apis.ContextUserKey, nil)
|
||||
client.Set(apis.ContextAuthRecordKey, nil)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -113,7 +164,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
Url: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -132,12 +183,12 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client - authorized user",
|
||||
Name: "existing client - authorized record",
|
||||
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",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -148,9 +199,9 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: 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")
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
}
|
||||
resetClient()
|
||||
},
|
||||
@@ -161,21 +212,21 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
Url: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
initialAuth := &models.User{}
|
||||
initialAuth := &models.Record{}
|
||||
initialAuth.RefreshId()
|
||||
client.Set(apis.ContextUserKey, initialAuth)
|
||||
client.Set(apis.ContextAuthRecordKey, initialAuth)
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: 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")
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
}
|
||||
resetClient()
|
||||
},
|
||||
@@ -187,55 +238,55 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeUserDeleteEvent(t *testing.T) {
|
||||
func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextUserKey, user)
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeUserUpdateEvent(t *testing.T) {
|
||||
func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
user1, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextUserKey, user1)
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the user and change its email
|
||||
user2, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
// refetch the authRecord and change its email
|
||||
authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user2.Email = "new@example.com"
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
|
||||
|
||||
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)
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +327,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAdminKey, admin1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the user and change its email
|
||||
// refetch the authRecord and change its email
|
||||
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -287,6 +338,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
|
||||
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)
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
|
||||
}
|
||||
}
|
||||
|
||||
-454
@@ -1,454 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"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 users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := api.exportRequestData(c)
|
||||
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection)).
|
||||
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
|
||||
|
||||
if admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryString(), &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 {
|
||||
failed := api.app.Dao().ExpandRecords(
|
||||
records,
|
||||
expands,
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
record,
|
||||
strings.Split(c.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(c, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
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 load 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 load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordCreateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
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 load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.RecordUpdateEvent{
|
||||
HttpContext: c,
|
||||
Record: record,
|
||||
}
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
// expand record relations
|
||||
failed := api.app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
|
||||
api.expandFunc(e.HttpContext, requestData),
|
||||
)
|
||||
if len(failed) > 0 && api.app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterDeleteRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
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) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"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/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// bindRecordAuthApi registers the auth record api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
api := recordAuthApi{app: app}
|
||||
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app, models.CollectionTypeAuth),
|
||||
)
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
subGroup.POST("/auth-with-password", api.authWithPassword)
|
||||
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, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
|
||||
subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
|
||||
subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
|
||||
}
|
||||
|
||||
type recordAuthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewNotFoundError("Missing auth record context.", nil)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthRefreshEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
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 *recordAuthApi) authMethods(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
|
||||
result := struct {
|
||||
UsernamePassword bool `json:"usernamePassword"`
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
}{
|
||||
UsernamePassword: authOptions.AllowUsernameAuth,
|
||||
EmailPassword: authOptions.AllowEmailAuth,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
if !authOptions.AllowOAuth2Auth {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
nameConfigMap := api.app.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)
|
||||
}
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(43)
|
||||
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 *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
if !collection.AuthOptions().AllowOAuth2Auth {
|
||||
return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
|
||||
}
|
||||
|
||||
var fallbackAuthRecord *models.Record
|
||||
|
||||
loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
|
||||
fallbackAuthRecord = loggedAuthRecord
|
||||
}
|
||||
|
||||
form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // either admin or the rule is empty
|
||||
}
|
||||
|
||||
if collection.CreateRule == nil {
|
||||
return errors.New("Only admins can create new accounts with OAuth2")
|
||||
}
|
||||
|
||||
if *collection.CreateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
|
||||
return fmt.Errorf("Failed create rule constraint: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
|
||||
return func(data *forms.RecordOAuth2LoginData) error {
|
||||
event.Record = data.Record
|
||||
event.OAuth2User = data.OAuth2User
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
data.Record = e.Record
|
||||
data.OAuth2User = e.OAuth2User
|
||||
|
||||
if err := next(data); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
e.Record = data.Record
|
||||
e.OAuth2User = data.OAuth2User
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthWithPasswordEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Password = form.Password
|
||||
event.Identity = form.Identity
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
|
||||
return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetRequest(api.app, collection)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmPasswordResetEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationRequest(api.app, collection)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return NewBadRequestError("An error occurred while validating the form.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordVerificationConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmVerificationEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
if record == nil {
|
||||
return NewUnauthorizedError("The request requires valid auth record.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeRequest(api.app, record)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordRequestEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeConfirm(api.app, collection)
|
||||
if readErr := c.Bind(form); readErr != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordConfirmEmailChangeEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordBeforeConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, id)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordListExternalAuthsEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuths = externalAuths
|
||||
|
||||
return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("Missing collection context.", nil)
|
||||
}
|
||||
|
||||
id := c.PathParam("id")
|
||||
provider := c.PathParam("provider")
|
||||
if id == "" || provider == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, id)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
|
||||
if err != nil {
|
||||
return NewNotFoundError("Missing external auth provider relation.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordUnlinkExternalAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return NewBadRequestError("Cannot unlink the external auth provider.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"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/search"
|
||||
)
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordCrudApi(app core.App, rg *echo.Group) {
|
||||
api := recordApi{app: app}
|
||||
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app),
|
||||
)
|
||||
|
||||
subGroup.GET("/records", api.list)
|
||||
subGroup.POST("/records", api.create)
|
||||
subGroup.GET("/records/:id", api.view)
|
||||
subGroup.PATCH("/records/:id", api.update)
|
||||
subGroup.DELETE("/records/: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 NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(
|
||||
api.app.Dao(),
|
||||
collection,
|
||||
requestData,
|
||||
// hidden fields are searchable only by admins
|
||||
requestData.Admin != nil,
|
||||
)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
var rawRecords = []dbx.NullStringMap{}
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &rawRecords)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
|
||||
|
||||
result.Items = records
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Records = records
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
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 NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
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.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordViewEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
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 NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
hasFullManageAccess := requestData.Admin != nil
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
testForm.SetFullManageAccess(true)
|
||||
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
return nil
|
||||
})
|
||||
|
||||
if testErr != nil {
|
||||
return NewBadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(hasFullManageAccess)
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordCreateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
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.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
|
||||
if collection == nil {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
recordId := c.PathParam("id")
|
||||
if recordId == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
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.Id, recordId, ruleFunc)
|
||||
if fetchErr != nil || record == nil {
|
||||
return NewNotFoundError("", fetchErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordDeleteEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.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 NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
// return cached to avoid copying the body multiple times
|
||||
if v := c.Get(ContextRequestDataKey); v != nil {
|
||||
if data, ok := v.(*models.RequestData); ok {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
}
|
||||
|
||||
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
||||
echo.BindQueryParams(c, &result.Query)
|
||||
rest.BindBody(c, &result.Data)
|
||||
|
||||
c.Set(ContextRequestDataKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Collection = authRecord.Collection()
|
||||
event.Record = authRecord
|
||||
event.Token = token
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
// allow always returning the email address of the authenticated account
|
||||
e.Record.IgnoreEmailVisibility(true)
|
||||
|
||||
// expand record relations
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
// create a copy of the cached request data and adjust it to the current auth record
|
||||
requestData := *RequestData(e.HttpContext)
|
||||
requestData.Admin = nil
|
||||
requestData.AuthRecord = e.Record
|
||||
failed := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
|
||||
// EnrichRecord parses the request context and enrich the provided record:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth record and its expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
|
||||
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
|
||||
}
|
||||
|
||||
// EnrichRecords parses the request context and enriches the provided records:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth records and their expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
|
||||
requestData := RequestData(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
|
||||
if len(expands) == 0 {
|
||||
return nil // nothing to expand
|
||||
}
|
||||
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Failed to expand: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(
|
||||
dao *daos.Dao,
|
||||
requestData *models.RequestData,
|
||||
) daos.ExpandFetchFunc {
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin != nil {
|
||||
return nil // admins 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(dao, relCollection, requestData, true)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
}
|
||||
|
||||
return records, err
|
||||
}
|
||||
}
|
||||
|
||||
// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
|
||||
// the provided record if the current auth model is admin, owner or a "manager".
|
||||
//
|
||||
// Note: Expects all records to be from the same auth collection!
|
||||
func autoIgnoreAuthRecordsEmailVisibility(
|
||||
dao *daos.Dao,
|
||||
records []*models.Record,
|
||||
requestData *models.RequestData,
|
||||
) error {
|
||||
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if requestData.Admin != nil {
|
||||
for _, rec := range records {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
collection := records[0].Collection()
|
||||
|
||||
mappedRecords := make(map[string]*models.Record, len(records))
|
||||
recordIds := make([]any, len(records))
|
||||
for i, rec := range records {
|
||||
mappedRecords[rec.Id] = rec
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
|
||||
return nil // no manage rule to check
|
||||
}
|
||||
|
||||
// fetch the ids of the managed records
|
||||
// ---
|
||||
managedIds := []string{}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
|
||||
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Column(&managedIds); err != nil {
|
||||
return err
|
||||
}
|
||||
// ---
|
||||
|
||||
// ignore the email visibility check for the managed records
|
||||
for _, id := range managedIds {
|
||||
if rec, ok := mappedRecords[id]; ok {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have full
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (aka. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
requestData *models.RequestData,
|
||||
) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := record.Collection().AuthOptions().ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for admins (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestData == nil || requestData.AuthRecord == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestData(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
dummyRecord := &models.Record{}
|
||||
dummyRecord.Id = "id1"
|
||||
c.Set(apis.ContextAuthRecordKey, dummyRecord)
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "id2"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
result := apis.RequestData(c)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected *models.RequestData instance, got nil")
|
||||
}
|
||||
|
||||
if result.Method != http.MethodPost {
|
||||
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
|
||||
}
|
||||
|
||||
rawQuery, _ := json.Marshal(result.Query)
|
||||
expectedQuery := `{"test":"123"}`
|
||||
if v := string(rawQuery); v != expectedQuery {
|
||||
t.Fatalf("Expected Query %v, got %v", expectedQuery, v)
|
||||
}
|
||||
|
||||
rawData, _ := json.Marshal(result.Data)
|
||||
expectedData := `{"test":456}`
|
||||
if v := string(rawData); v != expectedData {
|
||||
t.Fatalf("Expected Data %v, got %v", expectedData, v)
|
||||
}
|
||||
|
||||
if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id {
|
||||
t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord)
|
||||
}
|
||||
|
||||
if result.Admin == nil || result.Admin.Id != dummyAdmin.Id {
|
||||
t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAuthResponse(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "id1"
|
||||
|
||||
nonAuthRecord, err := app.Dao().FindRecordById("demo1", "al1h9ijdeojtsjy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
meta any
|
||||
expectError bool
|
||||
expectedContent []string
|
||||
notExpectedContent []string
|
||||
expectedEvents map[string]int
|
||||
}{
|
||||
{
|
||||
name: "non auth record",
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
},
|
||||
notExpectedContent: []string{
|
||||
`"meta":`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid auth record - with meta",
|
||||
record: authRecord,
|
||||
meta: map[string]any{"meta_test": 123},
|
||||
expectError: false,
|
||||
expectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"id":"`,
|
||||
`"expand":{"rel":{`,
|
||||
`"meta":{"meta_test":123`,
|
||||
},
|
||||
expectedEvents: map[string]int{
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
app.ResetEventCalls()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
responseErr := apis.RecordAuthResponse(app, c, s.record, s.meta)
|
||||
|
||||
hasErr := responseErr != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, responseErr)
|
||||
}
|
||||
|
||||
if len(app.EventCalls) != len(s.expectedEvents) {
|
||||
t.Fatalf("[%s] Expected events \n%v, \ngot \n%v", s.name, s.expectedEvents, app.EventCalls)
|
||||
}
|
||||
for k, v := range s.expectedEvents {
|
||||
if app.EventCalls[k] != v {
|
||||
t.Fatalf("[%s] Expected event %s to be called %d times, got %d", s.name, k, v, app.EventCalls[k])
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
response := rec.Body.String()
|
||||
|
||||
for _, v := range s.expectedContent {
|
||||
if !strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Missing %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range s.notExpectedContent {
|
||||
if strings.Contains(response, v) {
|
||||
t.Fatalf("[%s] Unexpected %v in response \n%v", s.name, v, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRecords(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
dummyAdmin := &models.Admin{}
|
||||
dummyAdmin.Id = "test_id"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
apis.EnrichRecords(c, app.Dao(), records, "rel_one")
|
||||
|
||||
for _, record := range records {
|
||||
expand := record.Expand()
|
||||
if len(expand) == 0 {
|
||||
t.Fatalf("Expected non-empty expand, got nil for record %v", record)
|
||||
}
|
||||
|
||||
if len(record.GetStringSlice("rel_one")) != 0 {
|
||||
if _, ok := expand["rel_one"]; !ok {
|
||||
t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand)
|
||||
}
|
||||
}
|
||||
|
||||
if len(record.GetStringSlice("rel_many")) != 0 {
|
||||
if _, ok := expand["rel_many"]; !ok {
|
||||
t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1052
File diff suppressed because it is too large
Load Diff
+35
-29
@@ -1,18 +1,20 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// BindSettingsApi registers the settings api endpoints.
|
||||
func BindSettingsApi(app core.App, rg *echo.Group) {
|
||||
// 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())
|
||||
@@ -29,13 +31,12 @@ type settingsApi struct {
|
||||
func (api *settingsApi) list(c echo.Context) error {
|
||||
settings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsListEvent{
|
||||
HttpContext: c,
|
||||
RedactedSettings: settings,
|
||||
}
|
||||
event := new(core.SettingsListEvent)
|
||||
event.HttpContext = c
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
@@ -47,26 +48,26 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
event := &core.SettingsUpdateEvent{
|
||||
HttpContext: c,
|
||||
OldSettings: api.app.Settings(),
|
||||
NewSettings: form.Settings,
|
||||
}
|
||||
event := new(core.SettingsUpdateEvent)
|
||||
event.HttpContext = c
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
if err := next(e.NewSettings); err != nil {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
@@ -75,7 +76,9 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnSettingsAfterUpdateRequest().Trigger(event)
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
@@ -83,23 +86,26 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
|
||||
func (api *settingsApi) testS3(c echo.Context) error {
|
||||
if !api.app.Settings().S3.Enabled {
|
||||
return rest.NewBadRequestError("S3 storage is not enabled.", nil)
|
||||
return NewBadRequestError("S3 storage is not enabled.", nil)
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
|
||||
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt"
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return rest.NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
if err := fs.Delete(testFileKey); err != nil {
|
||||
return rest.NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
@@ -110,18 +116,18 @@ func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
// form error
|
||||
return rest.NewBadRequestError("Failed to send the test email.", fErr)
|
||||
return NewBadRequestError("Failed to send the test email.", fErr)
|
||||
}
|
||||
|
||||
// mailer error
|
||||
return rest.NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
|
||||
return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
||||
+81
-56
@@ -19,11 +19,11 @@ func TestSettingsList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/settings",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -33,7 +33,7 @@ func TestSettingsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/settings",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -43,16 +43,26 @@ func TestSettingsList(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"twitterAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -68,7 +78,7 @@ func TestSettingsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsSet(t *testing.T) {
|
||||
validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
|
||||
validData := `{"meta":{"appName":"update_test"}}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -80,12 +90,12 @@ func TestSettingsSet(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -96,7 +106,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(``),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -106,20 +116,28 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"Acme"`,
|
||||
`"minPasswordLength":8`,
|
||||
`"appName":"acme_test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -132,15 +150,14 @@ func TestSettingsSet(t *testing.T) {
|
||||
Name: "authorized as admin submitting invalid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
|
||||
Body: strings.NewReader(`{"meta":{"appName":""}}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
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."}}`,
|
||||
`"meta":{"appName":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,7 +166,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -159,20 +176,29 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"twitterAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"microsoftAuth":{`,
|
||||
`"spotifyAuth":{`,
|
||||
`"kakaoAuth":{`,
|
||||
`"twitchAuth":{`,
|
||||
`"stravaAuth":{`,
|
||||
`"giteeAuth":{`,
|
||||
`"livechatAuth":{`,
|
||||
`"authentikAuth":{`,
|
||||
`"giteaAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
`"minPasswordLength":12`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
@@ -198,11 +224,11 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -212,12 +238,11 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
// @todo consider creating a test S3 filesystem
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -239,7 +264,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as user",
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -247,7 +272,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -258,7 +283,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -269,7 +294,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
Url: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -286,26 +311,26 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[verification] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Verify") {
|
||||
t.Fatalf("[verification] Expected to sent a verification email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeUserVerificationSend": 1,
|
||||
"OnMailerAfterUserVerificationSend": 1,
|
||||
"OnMailerBeforeRecordVerificationSend": 1,
|
||||
"OnMailerAfterRecordVerificationSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -317,26 +342,26 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[password-reset] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Reset password") {
|
||||
t.Fatalf("[password-reset] Expected to sent a password-reset email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeUserResetPasswordSend": 1,
|
||||
"OnMailerAfterUserResetPasswordSend": 1,
|
||||
"OnMailerBeforeRecordResetPasswordSend": 1,
|
||||
"OnMailerAfterRecordResetPasswordSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -348,26 +373,26 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if app.TestMailer.LastToAddress.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastToAddress.Address)
|
||||
if app.TestMailer.LastMessage.To.Address != "test@example.com" {
|
||||
t.Fatalf("[email-change] Expected the email to be sent to %s, got %s", "test@example.com", app.TestMailer.LastMessage.To.Address)
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastHtmlBody, "Confirm new email") {
|
||||
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastHtmlSubject, app.TestMailer.LastHtmlBody)
|
||||
if !strings.Contains(app.TestMailer.LastMessage.HTML, "Confirm new email") {
|
||||
t.Fatalf("[email-change] Expected to sent a confirm new email email, got \n%v\n%v", app.TestMailer.LastMessage.Subject, app.TestMailer.LastMessage.HTML)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnMailerBeforeUserChangeEmailSend": 1,
|
||||
"OnMailerAfterUserChangeEmailSend": 1,
|
||||
"OnMailerBeforeRecordChangeEmailSend": 1,
|
||||
"OnMailerAfterRecordChangeEmailSend": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-519
@@ -1,519 +0,0 @@
|
||||
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"))
|
||||
subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
|
||||
subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, 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(43)
|
||||
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 occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, authData, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("Failed to authenticate.", 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 occurred while loading 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 occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred 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 occurred while loading 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 occurred while loading 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 occurred while loading 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 occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
if err := form.Validate(); err != nil {
|
||||
return rest.NewBadRequestError("An error occurred 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 occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
user, submitErr := form.Submit()
|
||||
if submitErr != nil {
|
||||
return rest.NewBadRequestError("An error occurred 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 load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserCreateEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
// create the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to create user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterCreateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
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 load the submitted data due to invalid formatting.", err)
|
||||
}
|
||||
|
||||
event := &core.UserUpdateEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
}
|
||||
|
||||
// update the user
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
|
||||
if err := next(); err != nil {
|
||||
return rest.NewBadRequestError("Failed to update user.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.User)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnUserAfterUpdateRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (api *userApi) listExternalAuths(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)
|
||||
}
|
||||
|
||||
externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||
if err != nil {
|
||||
return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
|
||||
}
|
||||
|
||||
event := &core.UserListExternalAuthsEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
ExternalAuths: externalAuths,
|
||||
}
|
||||
|
||||
return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
|
||||
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
|
||||
})
|
||||
}
|
||||
|
||||
func (api *userApi) unlinkExternalAuth(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
provider := c.PathParam("provider")
|
||||
if id == "" || provider == "" {
|
||||
return rest.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
user, err := api.app.Dao().FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return rest.NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
|
||||
if err != nil {
|
||||
return rest.NewNotFoundError("Missing external auth provider relation.", err)
|
||||
}
|
||||
|
||||
event := &core.UserUnlinkExternalAuthEvent{
|
||||
HttpContext: c,
|
||||
User: user,
|
||||
ExternalAuth: externalAuth,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return rest.NewBadRequestError("Cannot unlink the external auth provider. Make sure that the user has other linked auth providers OR has an email address.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
-1113
File diff suppressed because it is too large
Load Diff
-241
@@ -1,241 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"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 name [folder] - creates new migration template file.
|
||||
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
|
||||
`
|
||||
var databaseFlag string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Executes DB migration scripts",
|
||||
ValidArgs: []string{"up", "down", "create", "collections"},
|
||||
Long: desc,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd := ""
|
||||
if len(args) > 0 {
|
||||
cmd = args[0]
|
||||
}
|
||||
|
||||
// additional commands
|
||||
// ---
|
||||
if cmd == "create" {
|
||||
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cmd == "collections" {
|
||||
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// ---
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate create
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const defaultMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
// add up queries...
|
||||
|
||||
return nil
|
||||
}, func(db dbx.Builder) error {
|
||||
// add down queries...
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCreateHandler(template string, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("Missing migration file name")
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
|
||||
var dir string
|
||||
if len(args) == 2 {
|
||||
dir = args[1]
|
||||
}
|
||||
if dir == "" {
|
||||
// If not specified, auto point to the default migrations folder.
|
||||
//
|
||||
// NB!
|
||||
// Since the create command makes sense only during development,
|
||||
// it is expected the user to be in the app working directory
|
||||
// and to be using `go run`
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir = path.Join(wd, "migrations")
|
||||
}
|
||||
|
||||
resultFilePath := path.Join(
|
||||
dir,
|
||||
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
|
||||
)
|
||||
|
||||
confirm := false
|
||||
prompt := &survey.Confirm{
|
||||
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
|
||||
}
|
||||
survey.AskOne(prompt, &confirm)
|
||||
if !confirm {
|
||||
fmt.Println("The command has been cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensure that migrations dir exist
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
|
||||
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully created file %q\n", resultFilePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// migrate collections
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const collectionsMigrateCreateTemplate = `package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// Auto generated migration with the most recent collections configuration.
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
jsonData := ` + "`" + `%s` + "`" + `
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return daos.New(db).ImportCollections(collections, true, nil)
|
||||
}, func(db dbx.Builder) error {
|
||||
// no revert since the configuration on the environment, on which
|
||||
// the migration was executed, could have changed via the UI/API
|
||||
return nil
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
func migrateCollectionsHandler(app core.App, args []string) error {
|
||||
createArgs := []string{"collections_snapshot"}
|
||||
createArgs = append(createArgs, args...)
|
||||
|
||||
dao := daos.New(app.DB())
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
|
||||
return fmt.Errorf("Failed to fetch migrations list: %v", err)
|
||||
}
|
||||
|
||||
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to serialize collections list: %v", err)
|
||||
}
|
||||
|
||||
return migrateCreateHandler(
|
||||
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
|
||||
createArgs,
|
||||
)
|
||||
}
|
||||
+21
-3
@@ -10,8 +10,11 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"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"
|
||||
"golang.org/x/crypto/acme"
|
||||
@@ -38,7 +41,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
// (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("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
@@ -75,7 +78,8 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 60 * time.Second,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
@@ -137,8 +141,22 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := migrationsConnectionsMap(app)
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9+
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
|
||||
Long: `
|
||||
Upgrades your existing pb_data to be compatible with the v0.8.x changes
|
||||
Prerequisites and caveats:
|
||||
- already upgraded to v0.7.*
|
||||
- no existing users collection
|
||||
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
|
||||
`,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
if err := upgrade(app); err != nil {
|
||||
color.Red("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func upgrade(app core.App) error {
|
||||
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
|
||||
}
|
||||
|
||||
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := migrateCollections(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUsers(app, txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resetMigrationsTable(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Println("The pb_data upgrade completed successfully!")
|
||||
bold.Println("You can now start the application as usual with the 'serve' command.")
|
||||
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func migrateCollections(txDao *daos.Dao) error {
|
||||
// add new collection columns
|
||||
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleReplacements := []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"expand", "expand2"},
|
||||
{"collecitonId", "collectionId2"},
|
||||
{"collecitonName", "collectionName2"},
|
||||
{"profile.userId", "profile.id"},
|
||||
|
||||
// @collection.*
|
||||
{"@collection.profiles.userId", "@collection.users.id"},
|
||||
{"@collection.profiles.username", "@collection.users.username2"},
|
||||
{"@collection.profiles.email", "@collection.users.email2"},
|
||||
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
|
||||
{"@collection.profiles.verified", "@collection.users.verified2"},
|
||||
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
|
||||
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
|
||||
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
|
||||
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
|
||||
{"@collection.profiles.", "@collection.users."},
|
||||
|
||||
// @request.*
|
||||
{"@request.user.profile.userId", "@request.auth.id"},
|
||||
{"@request.user.profile.username", "@request.auth.username2"},
|
||||
{"@request.user.profile.email", "@request.auth.email2"},
|
||||
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
|
||||
{"@request.user.profile.verified", "@request.auth.verified2"},
|
||||
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
|
||||
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
|
||||
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
|
||||
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
|
||||
{"@request.user.profile.", "@request.auth."},
|
||||
{"@request.user", "@request.auth"},
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.NormalizeOptions()
|
||||
|
||||
// rename profile fields
|
||||
// ---
|
||||
fieldsToRename := []string{
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"expand",
|
||||
}
|
||||
if collection.Name == "profiles" {
|
||||
fieldsToRename = append(fieldsToRename,
|
||||
"username",
|
||||
"email",
|
||||
"emailVisibility",
|
||||
"verified",
|
||||
"tokenKey",
|
||||
"passwordHash",
|
||||
"lastResetSentAt",
|
||||
"lastVerificationSentAt",
|
||||
)
|
||||
}
|
||||
for _, name := range fieldsToRename {
|
||||
f := collection.Schema.GetFieldByName(name)
|
||||
if f != nil {
|
||||
color.Blue("[%s - renamed field]", collection.Name)
|
||||
color.Yellow(" - old: %s", f.Name)
|
||||
color.Green(" - new: %s2", f.Name)
|
||||
fmt.Println()
|
||||
f.Name += "2"
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
// replace rule fields
|
||||
// ---
|
||||
rules := map[string]*string{
|
||||
"ListRule": collection.ListRule,
|
||||
"ViewRule": collection.ViewRule,
|
||||
"CreateRule": collection.CreateRule,
|
||||
"UpdateRule": collection.UpdateRule,
|
||||
"DeleteRule": collection.DeleteRule,
|
||||
}
|
||||
|
||||
for ruleKey, rule := range rules {
|
||||
if rule == nil || *rule == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
originalRule := *rule
|
||||
|
||||
for _, replacement := range ruleReplacements {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
|
||||
*rule = re.ReplaceAllString(*rule, replacement.new)
|
||||
}
|
||||
|
||||
*rule = replaceReversedLikes(*rule)
|
||||
|
||||
if originalRule != *rule {
|
||||
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
|
||||
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
|
||||
color.Green(" - new: %s", strings.TrimSpace(*rule))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
if err := txDao.SaveCollection(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(app core.App, txDao *daos.Dao) error {
|
||||
color.Blue(`[merging "_users" and "profiles"]:`)
|
||||
|
||||
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalProfilesCollectionId := profilesCollection.Id
|
||||
|
||||
// change the profiles collection id to something else since we will be using
|
||||
// it for the new users collection in order to avoid renaming the storage dir
|
||||
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{_collections}}
|
||||
SET id = '%s'
|
||||
WHERE id = '%s';
|
||||
`,
|
||||
(originalProfilesCollectionId + "__old__"),
|
||||
originalProfilesCollectionId,
|
||||
)).Execute()
|
||||
if idRenameErr != nil {
|
||||
return idRenameErr
|
||||
}
|
||||
|
||||
// refresh profiles collection
|
||||
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersSchema, _ := profilesCollection.Schema.Clone()
|
||||
userIdField := usersSchema.GetFieldByName("userId")
|
||||
if userIdField != nil {
|
||||
usersSchema.RemoveField(userIdField.Id)
|
||||
}
|
||||
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
usersCollection.Id = originalProfilesCollectionId
|
||||
usersCollection.Name = "users"
|
||||
usersCollection.Type = models.CollectionTypeAuth
|
||||
usersCollection.Schema = *usersSchema
|
||||
usersCollection.CreateRule = types.Pointer("")
|
||||
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
|
||||
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
|
||||
usersCollection.ListRule = profilesCollection.ListRule
|
||||
}
|
||||
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
|
||||
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
|
||||
usersCollection.ViewRule = profilesCollection.ViewRule
|
||||
}
|
||||
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
|
||||
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
|
||||
usersCollection.UpdateRule = profilesCollection.UpdateRule
|
||||
}
|
||||
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
|
||||
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
|
||||
usersCollection.DeleteRule = profilesCollection.DeleteRule
|
||||
}
|
||||
|
||||
// set auth options
|
||||
settings := app.Settings()
|
||||
authOptions := usersCollection.AuthOptions()
|
||||
authOptions.ManageRule = nil
|
||||
authOptions.AllowOAuth2Auth = true
|
||||
authOptions.AllowUsernameAuth = false
|
||||
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
|
||||
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
|
||||
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
|
||||
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
|
||||
// twitter currently is the only provider that doesn't return an email
|
||||
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
|
||||
|
||||
usersCollection.SetOptions(authOptions)
|
||||
|
||||
if err := txDao.SaveCollection(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy the original users
|
||||
_, usersErr := txDao.DB().NewQuery(`
|
||||
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
|
||||
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
|
||||
FROM {{_users}};
|
||||
`).Execute()
|
||||
if usersErr != nil {
|
||||
return usersErr
|
||||
}
|
||||
|
||||
// generate the profile fields copy statements
|
||||
sets := []string{"id = p.id"}
|
||||
for _, f := range usersSchema.Fields() {
|
||||
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
|
||||
}
|
||||
|
||||
// copy profile fields
|
||||
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE {{users}} as u
|
||||
SET %s
|
||||
FROM {{profiles}} as p
|
||||
WHERE u.id = p.userId;
|
||||
`, strings.Join(sets, ", "))).Execute()
|
||||
if copyProfileErr != nil {
|
||||
return copyProfileErr
|
||||
}
|
||||
|
||||
profileRecords, err := txDao.FindRecordsByExpr("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update all profiles and users fields to point to the new users collection
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, collection := range collections {
|
||||
var hasChanges bool
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
f.InitOptions()
|
||||
|
||||
if f.Type == schema.FieldTypeUser {
|
||||
if collection.Name == "profiles" && f.Name == "userId" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasChanges = true
|
||||
|
||||
// change the user field to a relation field
|
||||
options, _ := f.Options.(*schema.UserOptions)
|
||||
f.Type = schema.FieldTypeRelation
|
||||
f.Options = &schema.RelationOptions{
|
||||
CollectionId: usersCollection.Id,
|
||||
MaxSelect: &options.MaxSelect,
|
||||
CascadeDelete: options.CascadeDelete,
|
||||
}
|
||||
|
||||
for _, p := range profileRecords {
|
||||
pId := p.Id
|
||||
pUserId := p.GetString("userId")
|
||||
// replace all user record id references with the profile id
|
||||
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
|
||||
WHERE [[%s]] LIKE ('%%%s%%');
|
||||
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop _users table
|
||||
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop profiles table
|
||||
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete profiles collection
|
||||
if err := txDao.Delete(profilesCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
|
||||
_, alterErr := txDao.DB().NewQuery(`
|
||||
-- crate new externalAuths table
|
||||
CREATE TABLE {{_newExternalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- copy all data from the old table to the new one
|
||||
INSERT INTO {{_newExternalAuths}}
|
||||
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
|
||||
FROM {{_externalAuths}} auth
|
||||
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
|
||||
|
||||
-- drop old table
|
||||
DROP TABLE {{_externalAuths}};
|
||||
|
||||
-- rename new table
|
||||
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
|
||||
|
||||
-- create named indexes
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
|
||||
return alterErr
|
||||
}
|
||||
|
||||
func resetMigrationsTable(txDao *daos.Dao) error {
|
||||
// reset the migration state to the new init
|
||||
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
|
||||
"file": "1661586591_add_externalAuths_table.go",
|
||||
}).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
|
||||
|
||||
func replaceReversedLikes(rule string) string {
|
||||
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if len(p) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
|
||||
|
||||
rule = strings.ReplaceAll(rule, p[0], newPart)
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
+400
-79
@@ -6,6 +6,7 @@ package core
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
|
||||
// App defines the main PocketBase app interface.
|
||||
type App interface {
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the app db instance from app.Dao().DB() or
|
||||
// if you want more flexibility - app.Dao().ConcurrentDB() and app.Dao().NonconcurrentDB().
|
||||
//
|
||||
// DB returns the default app database instance.
|
||||
DB() *dbx.DB
|
||||
|
||||
@@ -25,6 +31,11 @@ type App interface {
|
||||
// trying to access the request logs table will result in error.
|
||||
Dao() *daos.Dao
|
||||
|
||||
// Deprecated:
|
||||
// This method may get removed in the near future.
|
||||
// It is recommended to access the logs db instance from app.LogsDao().DB() or
|
||||
// if you want more flexibility - app.LogsDao().ConcurrentDB() and app.LogsDao().NonconcurrentDB().
|
||||
//
|
||||
// LogsDB returns the app logs database instance.
|
||||
LogsDB() *dbx.DB
|
||||
|
||||
@@ -47,7 +58,7 @@ type App interface {
|
||||
IsDebug() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *Settings
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
Cache() *store.Store[any]
|
||||
@@ -67,8 +78,14 @@ type App interface {
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
// (aka. whether Bootstrap() was called).
|
||||
IsBootstrapped() bool
|
||||
|
||||
// Bootstrap takes care for initializing the application
|
||||
// (open db connections, load settings, etc.)
|
||||
// (open db connections, load settings, etc.).
|
||||
//
|
||||
// It will call ResetBootstrapState() if the application was already bootstrapped.
|
||||
Bootstrap() error
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -79,37 +96,79 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
// response to the client.
|
||||
// It could be used to log the final API error in external services.
|
||||
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
OnModelAfterCreate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*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]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
OnModelAfterUpdate() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelBeforeDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
OnModelAfterDelete() *hook.Hook[*ModelEvent]
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelAfterDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mailer event hooks
|
||||
@@ -126,38 +185,62 @@ type App interface {
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to a user.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserResetPasswordSend hook is triggered after
|
||||
// a user password reset email was successfully sent.
|
||||
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordResetPasswordSend hook is triggered after
|
||||
// an auth record password reset email was successfully sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserVerificationSend hook is triggered right before
|
||||
// sending a verification email to a user.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserVerificationSend hook is triggered after a user
|
||||
// verification email was successfully sent.
|
||||
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordVerificationSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to a a user.
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerBeforeRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
|
||||
// change address email was successfully sent.
|
||||
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordChangeEmailSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
@@ -167,6 +250,21 @@ type App interface {
|
||||
// the SSE client connection.
|
||||
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectEvent]
|
||||
|
||||
// OnRealtimeDisconnectRequest hook is triggered on disconnected/interrupted
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// an SSE message to a client.
|
||||
//
|
||||
// Returning [hook.StopPropagation] will prevent sending the message.
|
||||
// Returning any other non-nil error will close the realtime connection.
|
||||
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeSubscribeRequest hook is triggered before changing
|
||||
// the client subscriptions, allowing you to further validate and
|
||||
// modify the submitted change.
|
||||
@@ -207,7 +305,7 @@ type App interface {
|
||||
//
|
||||
// Could be used to validate or modify the file response before
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest() *hook.Hook[*FileDownloadEvent]
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
@@ -263,122 +361,345 @@ type App interface {
|
||||
// authenticated admin data and token.
|
||||
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// User API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnUsersListRequest hook is triggered on each API Users list request.
|
||||
// OnAdminBeforeAuthWithPasswordRequest hook is triggered before each Admin
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUsersListRequest() *hook.Hook[*UsersListEvent]
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Admin identity (by assigning [AdminAuthWithPasswordEvent.Admin]).
|
||||
OnAdminBeforeAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnUserViewRequest hook is triggered on each API User view request.
|
||||
// OnAdminAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Admin auth with password API request.
|
||||
OnAdminAfterAuthWithPasswordRequest() *hook.Hook[*AdminAuthWithPasswordEvent]
|
||||
|
||||
// OnAdminBeforeAuthRefreshRequest hook is triggered before each Admin
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUserViewRequest() *hook.Hook[*UserViewEvent]
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnUserBeforeCreateRequest hook is triggered before each API User
|
||||
// create request (after request data load and before model persistence).
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
OnAdminAfterAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminBeforeRequestPasswordResetRequest hook is triggered before each Admin
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
OnAdminAfterRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminBeforeConfirmPasswordResetRequest hook is triggered before each Admin
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserAfterCreateRequest hook is triggered after each
|
||||
// successful API User create request.
|
||||
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
OnAdminAfterConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserBeforeUpdateRequest hook is triggered before each API User
|
||||
// update request (after request data load and before model persistence).
|
||||
// ---------------------------------------------------------------
|
||||
// Record Auth API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordAuthRequest hook is triggered on each successful API
|
||||
// record authentication request (sign-in, token refresh, etc.).
|
||||
//
|
||||
// Could be used to additionally validate or modify the authenticated
|
||||
// record data and token.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAuthRequest(tags ...string) *hook.TaggedHook[*RecordAuthEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithPasswordRequest hook is triggered before each Record
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordAfterAuthWithPasswordRequest hook is triggered after each
|
||||
// successful Record auth with password API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithPasswordRequest(tags ...string) *hook.TaggedHook[*RecordAuthWithPasswordEvent]
|
||||
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordAfterAuthWithOAuth2Request hook is triggered after each
|
||||
// successful Record OAuth2 API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthWithOAuth2Request(tags ...string) *hook.TaggedHook[*RecordAuthWithOAuth2Event]
|
||||
|
||||
// OnRecordBeforeAuthRefreshRequest hook is triggered before each Record
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordAfterAuthRefreshRequest hook is triggered after each
|
||||
// successful auth refresh API request (right after generating a new auth token).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordAfterRequestPasswordResetRequest hook is triggered after each
|
||||
// successful request password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordRequestPasswordResetEvent]
|
||||
|
||||
// OnRecordBeforeConfirmPasswordResetRequest hook is triggered before each Record
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserAfterUpdateRequest hook is triggered after each
|
||||
// successful API User update request.
|
||||
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
// OnRecordAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
// successful confirm password reset API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmPasswordResetRequest(tags ...string) *hook.TaggedHook[*RecordConfirmPasswordResetEvent]
|
||||
|
||||
// OnUserBeforeDeleteRequest hook is triggered before each API User
|
||||
// delete request (after model load and before actual deletion).
|
||||
// OnRecordBeforeRequestVerificationRequest hook is triggered before each Record
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordAfterRequestVerificationRequest hook is triggered after each
|
||||
// successful request verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestVerificationRequest(tags ...string) *hook.TaggedHook[*RecordRequestVerificationEvent]
|
||||
|
||||
// OnRecordBeforeConfirmVerificationRequest hook is triggered before each Record
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// 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.).
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// Could be used to additionally validate or modify the
|
||||
// authenticated user data and token.
|
||||
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnUserListExternalAuths hook is triggered on each API user's external auths list request.
|
||||
// OnRecordAfterConfirmVerificationRequest hook is triggered after each
|
||||
// successful confirm verification API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmVerificationRequest(tags ...string) *hook.TaggedHook[*RecordConfirmVerificationEvent]
|
||||
|
||||
// OnRecordBeforeRequestEmailChangeRequest hook is triggered before each Record request email change API request
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterRequestEmailChangeRequest hook is triggered after each
|
||||
// successful request email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterRequestEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordRequestEmailChangeEvent]
|
||||
|
||||
// OnRecordBeforeConfirmEmailChangeRequest hook is triggered before each Record
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordAfterConfirmEmailChangeRequest hook is triggered after each
|
||||
// successful confirm email change API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API user's external auth unlink request.
|
||||
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
// Record CRUD 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]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordsListRequest(tags ...string) *hook.TaggedHook[*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]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordViewRequest(tags ...string) *hook.TaggedHook[*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]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeCreateRequest(tags ...string) *hook.TaggedHook[*RecordCreateEvent]
|
||||
|
||||
// OnRecordAfterCreateRequest hook is triggered after each
|
||||
// successful API Record create request.
|
||||
OnRecordAfterCreateRequest() *hook.Hook[*RecordCreateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterCreateRequest(tags ...string) *hook.TaggedHook[*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]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUpdateRequest(tags ...string) *hook.TaggedHook[*RecordUpdateEvent]
|
||||
|
||||
// OnRecordAfterUpdateRequest hook is triggered after each
|
||||
// successful API Record update request.
|
||||
OnRecordAfterUpdateRequest() *hook.Hook[*RecordUpdateEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUpdateRequest(tags ...string) *hook.TaggedHook[*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]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// OnRecordAfterDeleteRequest hook is triggered after each
|
||||
// successful API Record delete request.
|
||||
OnRecordAfterDeleteRequest() *hook.Hook[*RecordDeleteEvent]
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterDeleteRequest(tags ...string) *hook.TaggedHook[*RecordDeleteEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Collection API event hooks
|
||||
|
||||
+493
-245
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// cleanup all stored settings
|
||||
if _, err := app.DB().NewQuery("DELETE from _params;").Execute(); err != nil {
|
||||
t.Fatalf("Failed to delete all test settings: %v", err)
|
||||
}
|
||||
|
||||
// check if the new settings are saved in the db
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal("Failed to refresh the settings after delete")
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
})
|
||||
param, err := app.Dao().FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected new settings to be persisted, got %v", err)
|
||||
}
|
||||
|
||||
// change the db entry and refresh the app settings (ensure that there was no db update)
|
||||
param.Value = types.JsonRaw([]byte(`{"example": 123}`))
|
||||
if err := app.Dao().SaveParam(param.Key, param.Value); err != nil {
|
||||
t.Fatalf("Failed to update the test settings: %v", err)
|
||||
}
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, nil)
|
||||
|
||||
// try to refresh again without doing any changes
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh the app settings without change: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, nil)
|
||||
}
|
||||
|
||||
func testEventCalls(t *testing.T, app *tests.TestApp, events map[string]int) {
|
||||
if len(events) != len(app.EventCalls) {
|
||||
t.Fatalf("Expected events doesn't match: \n%v, \ngot \n%v", events, app.EventCalls)
|
||||
}
|
||||
|
||||
for name, total := range events {
|
||||
if v, ok := app.EventCalls[name]; !ok || v != total {
|
||||
t.Fatalf("Expected events doesn't exist or match: \n%v, \ngot \n%v", events, app.EventCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-243
@@ -11,7 +11,11 @@ func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "test_env", true)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
t.Fatalf("expected dataDir %q, got %q", testDataDir, app.dataDir)
|
||||
@@ -42,14 +46,26 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !app.IsBootstrapped() {
|
||||
t.Fatal("Expected the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
if stat, err := os.Stat(testDataDir); err != nil || !stat.IsDir() {
|
||||
t.Fatal("Expected test data directory to be created.")
|
||||
}
|
||||
@@ -112,29 +128,33 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: 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.dao.ConcurrentDB() != app.DB() {
|
||||
t.Fatalf("Expected app.DB %v, got %v", app.DB(), app.dao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.logsDao != app.LogsDao() {
|
||||
t.Fatalf("Expected app.LogsDao %v, got %v", app.LogsDao(), app.logsDao)
|
||||
}
|
||||
|
||||
if app.logsDao.ConcurrentDB() != app.LogsDB() {
|
||||
t.Fatalf("Expected app.LogsDB %v, got %v", app.LogsDB(), app.logsDao.ConcurrentDB())
|
||||
}
|
||||
|
||||
if app.dataDir != app.DataDir() {
|
||||
t.Fatalf("Expected app.DataDir %v, got %v", app.DataDir(), app.dataDir)
|
||||
}
|
||||
@@ -162,245 +182,17 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
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.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
|
||||
t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
|
||||
}
|
||||
|
||||
if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
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)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -419,7 +211,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(testDataDir, "pb_test_env", false)
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *dbx.DB) error {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+9
-13
@@ -3,24 +3,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"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
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// additional pragmas not supported through the dsn string
|
||||
_, err := db.NewQuery(`
|
||||
pragma journal_size_limit = 100000000;
|
||||
`).Execute()
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, err
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+10
-4
@@ -3,14 +3,20 @@
|
||||
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)"
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dbx.MustOpen("sqlite", fmt.Sprintf("%s?%s", dbPath, pragmas))
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
+194
-49
@@ -4,6 +4,9 @@ import (
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -11,36 +14,85 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
func (e *BaseCollectionEvent) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0, 2)
|
||||
|
||||
if e.Collection.Id != "" {
|
||||
tags = append(tags, e.Collection.Id)
|
||||
}
|
||||
|
||||
if e.Collection.Name != "" {
|
||||
tags = append(tags, e.Collection.Name)
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Serve events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type BootstrapEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
HttpContext echo.Context
|
||||
Error error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Model DAO events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
||||
|
||||
type ModelEvent struct {
|
||||
Dao *daos.Dao
|
||||
Model models.Model
|
||||
}
|
||||
|
||||
func (e *ModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Mailer events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MailerUserEvent struct {
|
||||
type MailerRecordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
MailClient mailer.Mailer
|
||||
User *models.User
|
||||
Message *mailer.Message
|
||||
Record *models.Record
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
type MailerAdminEvent struct {
|
||||
MailClient mailer.Mailer
|
||||
Message *mailer.Message
|
||||
Admin *models.Admin
|
||||
Meta map[string]any
|
||||
}
|
||||
@@ -54,6 +106,17 @@ type RealtimeConnectEvent struct {
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
}
|
||||
|
||||
type RealtimeMessageEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
Message *subscriptions.Message
|
||||
}
|
||||
|
||||
type RealtimeSubscribeEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
@@ -66,46 +129,150 @@ type RealtimeSubscribeEvent struct {
|
||||
|
||||
type SettingsListEvent struct {
|
||||
HttpContext echo.Context
|
||||
RedactedSettings *Settings
|
||||
RedactedSettings *settings.Settings
|
||||
}
|
||||
|
||||
type SettingsUpdateEvent struct {
|
||||
HttpContext echo.Context
|
||||
OldSettings *Settings
|
||||
NewSettings *Settings
|
||||
OldSettings *settings.Settings
|
||||
NewSettings *settings.Settings
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Record API events data
|
||||
// Record CRUD API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordsListEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Records []*models.Record
|
||||
Result *search.Result
|
||||
}
|
||||
|
||||
type RecordViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type RecordAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type RecordAuthWithPasswordEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type RecordAuthWithOAuth2Event struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
type RecordAuthRefreshEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmPasswordResetEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmVerificationEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordRequestEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordConfirmEmailChangeEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
}
|
||||
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API events data
|
||||
// -------------------------------------------------------------------
|
||||
@@ -142,53 +309,26 @@ type AdminAuthEvent struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// User API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type UsersListEvent struct {
|
||||
type AdminAuthWithPasswordEvent struct {
|
||||
HttpContext echo.Context
|
||||
Users []*models.User
|
||||
Result *search.Result
|
||||
Admin *models.Admin
|
||||
Identity string
|
||||
Password string
|
||||
}
|
||||
|
||||
type UserViewEvent struct {
|
||||
type AdminAuthRefreshEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type UserCreateEvent struct {
|
||||
type AdminRequestPasswordResetEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
type UserUpdateEvent struct {
|
||||
type AdminConfirmPasswordResetEvent 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 UserListExternalAuthsEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type UserUnlinkExternalAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Admin *models.Admin
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -202,23 +342,27 @@ type CollectionsListEvent struct {
|
||||
}
|
||||
|
||||
type CollectionViewEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionCreateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionUpdateEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionDeleteEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
}
|
||||
|
||||
type CollectionsImportEvent struct {
|
||||
@@ -231,8 +375,9 @@ type CollectionsImportEvent struct {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FileDownloadEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Collection *models.Collection
|
||||
Record *models.Record
|
||||
FileField *schema.SchemaField
|
||||
ServedPath string
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestBaseCollectionEventTags(t *testing.T) {
|
||||
c1 := new(models.Collection)
|
||||
|
||||
c2 := new(models.Collection)
|
||||
c2.Id = "a"
|
||||
|
||||
c3 := new(models.Collection)
|
||||
c3.Name = "b"
|
||||
|
||||
c4 := new(models.Collection)
|
||||
c4.Id = "a"
|
||||
c4.Name = "b"
|
||||
|
||||
scenarios := []struct {
|
||||
collection *models.Collection
|
||||
expectedTags []string
|
||||
}{
|
||||
{c1, []string{}},
|
||||
{c2, []string{"a"}},
|
||||
{c3, []string{"b"}},
|
||||
{c4, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.BaseCollectionEvent)
|
||||
event.Collection = s.collection
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelEventTags(t *testing.T) {
|
||||
m1 := new(models.Admin)
|
||||
|
||||
c := new(models.Collection)
|
||||
c.Id = "a"
|
||||
c.Name = "b"
|
||||
m2 := models.NewRecord(c)
|
||||
|
||||
scenarios := []struct {
|
||||
model models.Model
|
||||
expectedTags []string
|
||||
}{
|
||||
{m1, []string{"_admins"}},
|
||||
{m2, []string{"a", "b"}},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
event := new(core.ModelEvent)
|
||||
event.Model = s.model
|
||||
|
||||
tags := event.Tags()
|
||||
|
||||
if len(s.expectedTags) != len(tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
|
||||
for _, tag := range s.expectedTags {
|
||||
if !list.ExistInSlice(tag, tags) {
|
||||
t.Fatalf("[%d] Expected %v tags, got %v", i, s.expectedTags, tags)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
// @todo consider caching the unverified claims
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
|
||||
|
||||
// IsAdminEmailUnique checks if the provided email address is not
|
||||
// already in use by other admins.
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.AdminQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
query := dao.AdminQuery().Select("count(*)").
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// DeleteAdmin deletes the provided Admin model.
|
||||
|
||||
+32
-12
@@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
|
||||
id string
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
|
||||
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
|
||||
{" ", true},
|
||||
{"missing", true},
|
||||
{"9q2trqumvlyr3bd", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
@@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid base key (password reset key for auth token)
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base token (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
@@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result1 != 2 {
|
||||
t.Fatalf("Expected 2 admins, got %d", result1)
|
||||
if result1 != 3 {
|
||||
t.Fatalf("Expected 3 admins, got %d", result1)
|
||||
}
|
||||
|
||||
// delete all
|
||||
@@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test@example.com", "", false},
|
||||
{"test2@example.com", "", false},
|
||||
{"test3@example.com", "", false},
|
||||
{"new@example.com", "", true},
|
||||
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
|
||||
{"test@example.com", "sywbhecnh46rhm0", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin3, err := app.Dao().FindAdminByEmail("test3@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 {
|
||||
if deleteErr2 != nil {
|
||||
t.Fatal(deleteErr2)
|
||||
}
|
||||
|
||||
// cannot delete the only remaining admin
|
||||
deleteErr3 := app.Dao().DeleteAdmin(admin3)
|
||||
if deleteErr3 == nil {
|
||||
t.Fatal("Expected delete error, got nil")
|
||||
}
|
||||
|
||||
|
||||
+161
-32
@@ -4,24 +4,44 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
// New creates a new Dao instance with the provided db builder.
|
||||
const DefaultMaxFailRetries = 5
|
||||
|
||||
// New creates a new Dao instance with the provided db builder
|
||||
// (for both async and sync db operations).
|
||||
func New(db dbx.Builder) *Dao {
|
||||
return NewMultiDB(db, db)
|
||||
}
|
||||
|
||||
// New creates a new Dao instance with the provided dedicated
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
db: db,
|
||||
concurrentDB: concurrentDB,
|
||||
nonconcurrentDB: nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
db dbx.Builder
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
|
||||
// @todo delete after removing Block and Continue
|
||||
sem *semaphore.Weighted
|
||||
mux sync.RWMutex
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
@@ -31,16 +51,78 @@ type Dao struct {
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
}
|
||||
|
||||
// DB returns the internal db builder (*dbx.DB or *dbx.TX).
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// Currently the default db builder is dao.concurrentDB but that may change in the future.
|
||||
func (dao *Dao) DB() dbx.Builder {
|
||||
return dao.db
|
||||
return dao.ConcurrentDB()
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the dao concurrent (aka. multiple open connections)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) ConcurrentDB() dbx.Builder {
|
||||
return dao.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the dao nonconcurrent (aka. single open connection)
|
||||
// db builder (*dbx.DB or *dbx.TX).
|
||||
//
|
||||
// In a transaction the concurrentDB and nonconcurrentDB refer to the same *dbx.TX instance.
|
||||
func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Block acquires a lock and blocks all other go routines that uses
|
||||
// the Dao instance until dao.Continue() is called, effectively making
|
||||
// the concurrent requests to perform synchronous db operations.
|
||||
//
|
||||
// This method should be used only as a last resort and as a workaround
|
||||
// for the SQLITE_BUSY error when mixing read&write in a transaction.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func someLongRunningTransaction() error {
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
// defer cancel()
|
||||
// if err := app.Dao().Block(ctx); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer app.Dao().Continue()
|
||||
//
|
||||
// return app.Dao().RunInTransaction(func (txDao *daos.Dao) error {
|
||||
// // some long running read&write transaction...
|
||||
// })
|
||||
// }
|
||||
func (dao *Dao) Block(ctx context.Context) error {
|
||||
if dao.sem == nil {
|
||||
dao.mux.Lock()
|
||||
dao.sem = semaphore.NewWeighted(1)
|
||||
dao.mux.Unlock()
|
||||
}
|
||||
|
||||
return dao.sem.Acquire(ctx, 1)
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in the next releases. Use [Dao.NonconcurrentDB()] instead.
|
||||
//
|
||||
// Continue releases the previously acquired Block() lock.
|
||||
func (dao *Dao) Continue() {
|
||||
if dao.sem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dao.sem.Release(1)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return dao.DB().Select("{{" + tableName + "}}.*").From(tableName)
|
||||
}
|
||||
|
||||
// FindById finds a single db record with the specified id and
|
||||
@@ -57,13 +139,23 @@ type afterCallGroup struct {
|
||||
|
||||
// RunInTransaction wraps fn into a transaction.
|
||||
//
|
||||
// It is safe to nest RunInTransaction calls.
|
||||
// It is safe to nest RunInTransaction calls as long as you use the txDao.
|
||||
func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
switch txOrDB := dao.db.(type) {
|
||||
switch txOrDB := dao.NonconcurrentDB().(type) {
|
||||
case *dbx.Tx:
|
||||
// nested transactions are not supported by default
|
||||
// so execute the function within the current transaction
|
||||
return fn(dao)
|
||||
// ---
|
||||
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
|
||||
txDao := New(txOrDB)
|
||||
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
|
||||
txDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
txDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
txDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
|
||||
return fn(txDao)
|
||||
case *dbx.DB:
|
||||
afterCalls := []afterCallGroup{}
|
||||
|
||||
@@ -107,14 +199,15 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(call.EventDao, call.Model)
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(call.EventDao, call.Model)
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(call.EventDao, call.Model)
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +215,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
return txError
|
||||
}
|
||||
|
||||
return errors.New("Failed to start transaction (unknown dao.db)")
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
}
|
||||
|
||||
// Delete deletes the provided model.
|
||||
@@ -131,30 +224,36 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
return errors.New("ID is not set")
|
||||
}
|
||||
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
if err := dao.BeforeDeleteFunc(dao, m); err != nil {
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := dao.db.Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
dao.AfterDeleteFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
// Save upserts (update or create if primary key is not set) the provided model.
|
||||
func (dao *Dao) Save(m models.Model) error {
|
||||
if m.IsNew() {
|
||||
return dao.create(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.create(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
return dao.update(m)
|
||||
return dao.failRetry(func(retryDao *Dao) error {
|
||||
return retryDao.update(m)
|
||||
}, DefaultMaxFailRetries)
|
||||
}
|
||||
|
||||
func (dao *Dao) update(m models.Model) error {
|
||||
@@ -177,7 +276,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.db.Update(
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
@@ -187,7 +286,7 @@ func (dao *Dao) update(m models.Model) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Update(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -228,18 +327,18 @@ func (dao *Dao) create(m models.Model) error {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.db.Model(m).Insert(); err != nil {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.UnmarkAsNew()
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
@@ -247,3 +346,33 @@ func (dao *Dao) create(m models.Model) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) failRetry(op func(retryDao *Dao) error, maxRetries int) error {
|
||||
retryDao := dao
|
||||
attempts := 1
|
||||
|
||||
Retry:
|
||||
if attempts == 2 {
|
||||
// assign new Dao without the before hooks to avoid triggering
|
||||
// the already fired before callbacks multiple times
|
||||
retryDao = NewMultiDB(dao.concurrentDB, dao.nonconcurrentDB)
|
||||
retryDao.AfterCreateFunc = dao.AfterCreateFunc
|
||||
retryDao.AfterUpdateFunc = dao.AfterUpdateFunc
|
||||
retryDao.AfterDeleteFunc = dao.AfterDeleteFunc
|
||||
}
|
||||
|
||||
// execute
|
||||
err := op(retryDao)
|
||||
|
||||
if err != nil &&
|
||||
attempts < maxRetries &&
|
||||
// note: we are checking the err message so that we can handle both the cgo and noncgo errors
|
||||
strings.Contains(err.Error(), "database is locked") {
|
||||
// wait and retry
|
||||
time.Sleep(time.Duration(200*attempts) * time.Millisecond)
|
||||
attempts++
|
||||
goto Retry
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
+190
-7
@@ -20,6 +20,25 @@ func TestNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMultiDB(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
|
||||
if dao.DB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[db-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.ConcurrentDB() != testApp.Dao().ConcurrentDB() {
|
||||
t.Fatal("[concurrentDB-concurrentDB] The 2 db instances are different")
|
||||
}
|
||||
|
||||
if dao.NonconcurrentDB() != testApp.Dao().NonconcurrentDB() {
|
||||
t.Fatal("[nonconcurrentDB-nonconcurrentDB] The 2 db instances are different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -35,8 +54,8 @@ func TestDaoModelQuery(t *testing.T) {
|
||||
"SELECT {{_collections}}.* FROM `_collections`",
|
||||
},
|
||||
{
|
||||
&models.User{},
|
||||
"SELECT {{_users}}.* FROM `_users`",
|
||||
&models.Admin{},
|
||||
"SELECT {{_admins}}.* FROM `_admins`",
|
||||
},
|
||||
{
|
||||
&models.Request{},
|
||||
@@ -64,19 +83,19 @@ func TestDaoFindById(t *testing.T) {
|
||||
// missing id
|
||||
{
|
||||
&models.Collection{},
|
||||
"00000000-075d-49fe-9d09-ea7e951000dc",
|
||||
"missing",
|
||||
true,
|
||||
},
|
||||
// existing collection id
|
||||
{
|
||||
&models.Collection{},
|
||||
"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"wsmn24bux7wo113",
|
||||
false,
|
||||
},
|
||||
// existing user id
|
||||
// existing admin id
|
||||
{
|
||||
&models.User{},
|
||||
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||
&models.Admin{},
|
||||
"sbmbsdb40jyxf7h",
|
||||
false,
|
||||
},
|
||||
}
|
||||
@@ -289,6 +308,170 @@ func TestDaoDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryCreate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterCreateHookCalls++
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{Email: "test@example.com"}
|
||||
if err := retryDao.Save(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeCreateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeCreateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterCreateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterCreateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryUpdate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
model, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterUpdateHookCalls++
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeUpdateHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeUpdateHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterUpdateHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterUpdateHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoRetryDelete(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init mock retry dao
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryAfterDeleteHookCalls++
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
if err := retryDao.Delete(model); err != nil {
|
||||
t.Fatalf("Expected nil after retry, got error: %v", err)
|
||||
}
|
||||
|
||||
// the before hook is expected to be called only once because
|
||||
// it is ignored after the first "database is locked" error
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected after hook calls to be 1, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
|
||||
dummy := &models.Admin{}
|
||||
dummy.RefreshId()
|
||||
dummy.MarkAsNotNew()
|
||||
if err := retryDao.Delete(dummy); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if retryBeforeDeleteHookCalls != 1 {
|
||||
t.Fatalf("Expected before hook calls to be 1, got %d", retryBeforeDeleteHookCalls)
|
||||
}
|
||||
|
||||
if retryAfterDeleteHookCalls != 0 {
|
||||
t.Fatalf("Expected after hook calls to be 0, got %d", retryAfterDeleteHookCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoBeforeHooksError(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
+53
-35
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
// CollectionQuery returns a new Collection select query.
|
||||
@@ -15,15 +16,31 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Collection{})
|
||||
}
|
||||
|
||||
// FindCollectionByNameOrId finds the first collection by its name or id.
|
||||
// FindCollectionsByType finds all collections by the given type.
|
||||
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
|
||||
collections := []*models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.HashExp{"type": collectionType}).
|
||||
OrderBy("created ASC").
|
||||
All(&collections)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// FindCollectionByNameOrId finds a single collection by its name (case insensitive) 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},
|
||||
)).
|
||||
AndWhere(dbx.NewExp("[[id]] = {:id} OR LOWER([[name]])={:name}", dbx.Params{
|
||||
"id": nameOrId,
|
||||
"name": strings.ToLower(nameOrId),
|
||||
})).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
@@ -37,58 +54,47 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
|
||||
// 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 {
|
||||
// Note: case insensitive check because the name is used also as a table name for the records.
|
||||
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.CollectionQuery().
|
||||
query := 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)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
}
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
// FindCollectionsWithUserFields finds all collections that has
|
||||
// at least one user schema field.
|
||||
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
|
||||
result := []*models.Collection{}
|
||||
var exists bool
|
||||
|
||||
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
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// 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
|
||||
// also included in the result. To exclude it, pass the collection id
|
||||
// as the excludeId argument.
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...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 {
|
||||
query := dao.CollectionQuery()
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
if err := query.All(&collections); 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 {
|
||||
@@ -152,6 +158,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// set default collection type
|
||||
if collection.Type == "" {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
@@ -196,7 +207,14 @@ func (dao *Dao) ImportCollections(
|
||||
imported.RefreshId()
|
||||
}
|
||||
|
||||
// set default type if missing
|
||||
if imported.Type == "" {
|
||||
imported.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
if existing, ok := mappedExisting[imported.GetId()]; ok {
|
||||
imported.MarkAsNotNew()
|
||||
|
||||
// preserve original created date
|
||||
if !existing.Created.IsZero() {
|
||||
imported.Created = existing.Created
|
||||
|
||||
+120
-124
@@ -3,6 +3,7 @@ package daos_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -24,6 +25,41 @@ func TestCollectionQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionsByType(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionType string
|
||||
expectError bool
|
||||
expectTotal int
|
||||
}{
|
||||
{"", false, 0},
|
||||
{"unknown", false, 0},
|
||||
{models.CollectionTypeAuth, false, 3},
|
||||
{models.CollectionTypeBase, false, 5},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
|
||||
|
||||
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(collections) != scenario.expectTotal {
|
||||
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.Type != scenario.collectionType {
|
||||
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -34,9 +70,9 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
}{
|
||||
{"", true},
|
||||
{"missing", true},
|
||||
{"00000000-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
|
||||
{"demo", false},
|
||||
{"wsmn24bux7wo113", false},
|
||||
{"demo1", false},
|
||||
{"DEMO1", false}, // case insensitive check
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -47,7 +83,7 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
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 {
|
||||
if model != nil && model.Id != scenario.nameOrId && !strings.EqualFold(model.Name, scenario.nameOrId) {
|
||||
t.Errorf("(%d) Expected model with identifier %s, got %v", i, scenario.nameOrId, model)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +99,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -76,38 +113,22 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := app.Dao().FindCollectionReferences(collection, collection.Id)
|
||||
result, err := app.Dao().FindCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,11 +137,18 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
|
||||
expectedFields := []string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_one_no_cascade_required",
|
||||
"rel_one_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"rel_many_no_cascade_required",
|
||||
"rel_many_cascade",
|
||||
}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo2" {
|
||||
t.Fatalf("Expected collection demo2, got %s", col.Name)
|
||||
if col.Name != "demo4" {
|
||||
t.Fatalf("Expected collection demo4, got %s", col.Name)
|
||||
}
|
||||
if len(fields) != len(expectedFields) {
|
||||
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
|
||||
@@ -138,7 +166,7 @@ func TestDeleteCollection(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
c0 := &models.Collection{}
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -146,18 +174,22 @@ func TestDeleteCollection(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3.System = true
|
||||
if err := app.Dao().Save(c3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{c0, true},
|
||||
{c1, true}, // is part of a reference
|
||||
{c2, false},
|
||||
{c1, false},
|
||||
{c2, true}, // is part of a reference
|
||||
{c3, true}, // system
|
||||
}
|
||||
|
||||
@@ -177,6 +209,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: "new_test",
|
||||
Type: models.CollectionTypeBase,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Type: schema.FieldTypeText,
|
||||
@@ -239,7 +272,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if the records table has the schema fields
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -262,31 +295,24 @@ func TestImportCollections(t *testing.T) {
|
||||
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
|
||||
expectError bool
|
||||
expectCollectionsCount int
|
||||
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
}{
|
||||
{
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
},
|
||||
{
|
||||
name: "check db constraints",
|
||||
jsonData: `[
|
||||
{"name": "import_test", "schema": []}
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
jsonData: `[
|
||||
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
|
||||
{"name": "import_test1", "schema": [{"name":"test", "type": "text"}]},
|
||||
{"name": "import_test2", "type": "auth"}
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 10,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@@ -298,7 +324,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@@ -310,13 +336,13 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 9,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@@ -346,50 +372,49 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
"id": "kpv709sk2lqbqk8",
|
||||
"system": true,
|
||||
"name": "nologin",
|
||||
"type": "auth",
|
||||
"options": {
|
||||
"allowEmailAuth": false,
|
||||
"allowOAuth2Auth": false,
|
||||
"allowUsernameAuth": false,
|
||||
"exceptEmailDomains": [],
|
||||
"manageRule": "@request.auth.collectionName = 'users'",
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": [],
|
||||
"requireEmail": true
|
||||
},
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"schema": [
|
||||
{
|
||||
"id":"koih1lqx",
|
||||
"name":"userId",
|
||||
"type":"user",
|
||||
"system":true,
|
||||
"required":true,
|
||||
"unique":true,
|
||||
"options":{
|
||||
"maxSelect":1,
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":false
|
||||
"id": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@@ -427,38 +452,8 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "test with deleteMissing: false",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"abcd_import",
|
||||
"name":"new_field",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo1",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -506,14 +501,15 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 9,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"profiles": 6,
|
||||
"demo": 3,
|
||||
"demo2": 14,
|
||||
"demo3": 1,
|
||||
"demo4": 6,
|
||||
"nologin": 1,
|
||||
"demo1": 15,
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo5": 5,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
||||
+14
-31
@@ -12,13 +12,16 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.ExternalAuth{})
|
||||
}
|
||||
|
||||
/// FindAllExternalAuthsByUserId returns all ExternalAuth models
|
||||
/// linked to the provided userId.
|
||||
func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) {
|
||||
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
|
||||
/// linked to the provided auth record.
|
||||
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
|
||||
auths := []*models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{"userId": userId}).
|
||||
AndWhere(dbx.HashExp{
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
}).
|
||||
OrderBy("created ASC").
|
||||
All(&auths)
|
||||
|
||||
@@ -50,15 +53,16 @@ func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByUserIdAndProvider returns the first available
|
||||
// ExternalAuth model for the specified userId and provider.
|
||||
func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) {
|
||||
// FindExternalAuthByRecordAndProvider returns the first available
|
||||
// ExternalAuth model for the specified record data and provider.
|
||||
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{
|
||||
"userId": userId,
|
||||
"provider": provider,
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
"provider": provider,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
@@ -74,7 +78,7 @@ func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*m
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
// has changed and no longer returns the expected fields
|
||||
if model.UserId == "" || model.Provider == "" || model.ProviderId == "" {
|
||||
if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
|
||||
return errors.New("Missing required ExternalAuth fields.")
|
||||
}
|
||||
|
||||
@@ -82,27 +86,6 @@ func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
}
|
||||
|
||||
// DeleteExternalAuth deletes the provided ExternalAuth model.
|
||||
//
|
||||
// The delete may fail if the linked user doesn't have an email and
|
||||
// there are no other linked ExternalAuth models available.
|
||||
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
|
||||
user, err := dao.FindUserById(model.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if the user doesn't have an email, make sure that there
|
||||
// is at least one other external auth relation available
|
||||
if user.Email == "" {
|
||||
allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(allExternalAuths) <= 1 {
|
||||
return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email address.")
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Delete(model)
|
||||
}
|
||||
|
||||
+38
-44
@@ -19,7 +19,7 @@ func TestExternalAuthQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -27,16 +27,20 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
userId string
|
||||
expectedCount int
|
||||
}{
|
||||
{"", 0},
|
||||
{"missing", 0},
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
|
||||
{"cx9u0dh2udo8xol", 2},
|
||||
{"oap640cot4yru2s", 0},
|
||||
{"4q1xlclmfloku33", 2},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId)
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected error %v", i, err)
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -45,8 +49,8 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, auth := range auths {
|
||||
if auth.UserId != s.userId {
|
||||
t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth)
|
||||
if auth.RecordId != record.Id {
|
||||
t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,8 +69,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "id1", "abcdefghijklmn0"},
|
||||
{"gitlab", "id2", "abcdefghijklmn1"},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -85,7 +89,7 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
|
||||
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -94,17 +98,19 @@ func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
|
||||
provider string
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"", "github", ""},
|
||||
{"123456", "github", ""}, // missing user and provider record
|
||||
{"123456", "google", ""}, // missing user but existing provider record
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
|
||||
{"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
|
||||
{"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
|
||||
{"bgs820n361vj1qd", "google", ""},
|
||||
{"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
|
||||
{"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider)
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
@@ -130,9 +136,10 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
auth := &models.ExternalAuth{
|
||||
UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||
Provider: "test",
|
||||
ProviderId: "test_id",
|
||||
RecordId: "o1y0dd0spd786md",
|
||||
CollectionId: "v851q4r790rhknl",
|
||||
Provider: "test",
|
||||
ProviderId: "test_id",
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveExternalAuth(auth); err != nil {
|
||||
@@ -154,42 +161,29 @@ func TestDeleteExternalAuth(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user, err := app.Dao().FindUserById("cx9u0dh2udo8xol")
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil {
|
||||
t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err)
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
|
||||
t.Fatal("Expected delete to fail, got nil")
|
||||
}
|
||||
|
||||
// update the user model and try again
|
||||
user.Email = "test_new@example.com"
|
||||
if err := app.Dao().SaveUser(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try to delete auths[1] again
|
||||
if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
|
||||
t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
|
||||
for _, auth := range auths {
|
||||
if err := app.Dao().DeleteExternalAuth(auth); err != nil {
|
||||
t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// check if the relations were really deleted
|
||||
newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||
newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(newAuths) != 0 {
|
||||
t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths)
|
||||
t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
|
||||
}
|
||||
}
|
||||
|
||||
+341
-226
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
@@ -23,16 +25,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
func (dao *Dao) FindRecordById(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
recordId string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableName := collection.Name
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{tableName + ".id": recordId})
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -49,22 +59,31 @@ func (dao *Dao) FindRecordById(
|
||||
// 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,
|
||||
collectionNameOrId string,
|
||||
recordIds []string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) ([]*models.Record, error) {
|
||||
tableName := collection.Name
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
|
||||
AndWhere(dbx.In(
|
||||
collection.Name+".id",
|
||||
list.ToInterfaceSlice(recordIds)...,
|
||||
))
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
rows := make([]dbx.NullStringMap, 0, len(recordIds))
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,24 +91,35 @@ func (dao *Dao) FindRecordsByIds(
|
||||
return models.NewRecordsFromNullStringMaps(collection, rows), nil
|
||||
}
|
||||
|
||||
// FindRecordsByExpr finds all records by the provided db expression.
|
||||
// If no records are found, returns an empty slice.
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
//
|
||||
// Returns all collection records if no expressions are provided.
|
||||
//
|
||||
// Returns an empty slice if no records are found.
|
||||
//
|
||||
// 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")
|
||||
//
|
||||
// expr1 := dbx.HashExp{"email": "test@example.com"}
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(expr).
|
||||
All(&rows)
|
||||
|
||||
if err != nil {
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -98,11 +128,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
|
||||
|
||||
// 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) {
|
||||
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{key: value}).
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
@@ -115,263 +150,343 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
// the unique check will be case insensitive.
|
||||
//
|
||||
// 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,
|
||||
collectionNameOrId string,
|
||||
key string,
|
||||
value any,
|
||||
excludeId string,
|
||||
excludeIds ...string,
|
||||
) bool {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if collection.IsAuth() && key == schema.FieldNameUsername {
|
||||
expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(cast.ToString(value)),
|
||||
})
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
Select("count(*)").
|
||||
AndWhere(expr).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
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
|
||||
return query.Row(&exists) == 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) {
|
||||
if user.Id == "" {
|
||||
return []*models.Record{}, nil
|
||||
}
|
||||
|
||||
collections, err := dao.FindCollectionsWithUserFields()
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
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...)
|
||||
// check required claims
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
collectionId, _ := unverifiedClaims["collectionId"].(string)
|
||||
if id == "" || collectionId == "" {
|
||||
return nil, errors.New("missing or invalid token claims")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
record, err := dao.FindRecordById(collectionId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !record.Collection().IsAuth() {
|
||||
return nil, errors.New("The token is not associated to an auth collection record.")
|
||||
}
|
||||
|
||||
verificationKey := record.TokenKey() + baseTokenKey
|
||||
|
||||
// verify token signature
|
||||
if _, err := security.ParseJWT(token, verificationKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch auth collection %q (%w)", collectionNameOrId, err)
|
||||
}
|
||||
if !collection.IsAuth() {
|
||||
return nil, fmt.Errorf("%q is not an auth collection", collectionNameOrId)
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(username),
|
||||
})).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
|
||||
// and return a new "unique" username with appended random numeric part
|
||||
// (eg. "existingName" -> "existingName583").
|
||||
//
|
||||
// The same username will be returned if the provided string is already unique.
|
||||
func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
collectionNameOrId string,
|
||||
baseUsername string,
|
||||
excludeIds ...string,
|
||||
) string {
|
||||
username := baseUsername
|
||||
|
||||
for i := 0; i < 10; i++ { // max 10 attempts
|
||||
isUnique := dao.IsRecordValueUnique(
|
||||
collectionNameOrId,
|
||||
schema.FieldNameUsername,
|
||||
username,
|
||||
excludeIds...,
|
||||
)
|
||||
if isUnique {
|
||||
break // already unique
|
||||
}
|
||||
username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// SaveRecord upserts the provided Record model.
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
return errors.New("unable to save auth record without username")
|
||||
}
|
||||
|
||||
// Cross-check that the auth record id is unique for all auth collections.
|
||||
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
|
||||
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to fetch the auth collections for cross-id unique check: %w", err)
|
||||
}
|
||||
for _, collection := range authCollections {
|
||||
if record.Collection().Id == collection.Id {
|
||||
continue // skip current collection (sqlite will do the check for us)
|
||||
}
|
||||
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
|
||||
if !isUnique {
|
||||
return errors.New("the auth record ID must be unique across all auth collections")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
// relational records (delete or unset, 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).
|
||||
// reference in another record (aka. cannot be deleted or unset).
|
||||
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(), "")
|
||||
// fetch rel references (if any)
|
||||
//
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors 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 {
|
||||
// manually trigger delete on any linked external auth to ensure
|
||||
// that the `OnModel*` hooks are triggered
|
||||
if record.Collection().IsAuth() {
|
||||
// note: the select is outside of the transaction to minimize
|
||||
// SQLITE_BUSY errors when mixing read&write in a single transaction
|
||||
externalAuths, err := dao.FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, auth := range externalAuths {
|
||||
if err := txDao.DeleteExternalAuth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delete the record before the relation references to ensure that there
|
||||
// will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
|
||||
if err := txDao.Delete(record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txDao.cascadeRecordDelete(record, refs)
|
||||
})
|
||||
}
|
||||
|
||||
// cascadeRecordDelete triggers cascade deletion for the provided references.
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup in v0.12+
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
}
|
||||
|
||||
// trigger cascade for each batchSize rel items until there is none
|
||||
batchSize := 4000
|
||||
rows := make([]dbx.NullStringMap, 0, batchSize)
|
||||
for {
|
||||
if err := query.Limit(int64(batchSize)).All(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(rows)
|
||||
if total == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
err := dao.deleteRefRecords(mainRecord, refRecords, field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if total < batchSize {
|
||||
break // no more items
|
||||
}
|
||||
|
||||
rows = rows[:0] // keep allocated memory
|
||||
}
|
||||
}
|
||||
|
||||
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.EqualFold(oldTableName, newTableName) {
|
||||
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRefRecords checks 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).
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) deleteRefRecords(mainRecord *models.Record, refRecords []*models.Record, field *schema.SchemaField) error {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
return errors.New("relation field options are not initialized")
|
||||
}
|
||||
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == mainRecord.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 := dao.DeleteRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
// no further actions are needed (the reference is deleted)
|
||||
continue
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.RandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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, refRecord.Collection().Name)
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// save the reference changes
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := dao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
+106
-26
@@ -3,11 +3,16 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// MaxExpandDepth specifies the max allowed nested expand depth path.
|
||||
@@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
return failed
|
||||
}
|
||||
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
// 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
|
||||
// - indirect expands are supported only with single relation fields
|
||||
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.")
|
||||
@@ -53,29 +61,106 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
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 || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
|
||||
if !ok {
|
||||
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
|
||||
|
||||
var relField *schema.SchemaField
|
||||
var relFieldOptions *schema.RelationOptions
|
||||
var relCollection *models.Collection
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
|
||||
|
||||
if len(matches) == 3 {
|
||||
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
|
||||
if indirectRel == nil {
|
||||
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
|
||||
}
|
||||
|
||||
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
|
||||
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
|
||||
}
|
||||
|
||||
indirectRelField.InitOptions()
|
||||
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
|
||||
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
|
||||
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
|
||||
}
|
||||
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
|
||||
// for now don't allow multi-relation indirect fields expand
|
||||
// due to eventual poor query performance with large data sets.
|
||||
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
|
||||
}
|
||||
|
||||
recordIds := make([]any, len(records))
|
||||
for i, record := range records {
|
||||
recordIds[i] = record.Id
|
||||
}
|
||||
|
||||
// @todo after the index optimizations consider allowing
|
||||
// indirect expand for multi-relation fields
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
indirectRel.Id,
|
||||
dbx.In(inflector.Columnify(matches[2]), recordIds...),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
|
||||
for _, indirectRecord := range indirectRecords {
|
||||
recId := indirectRecord.GetString(matches[2])
|
||||
if recId != "" {
|
||||
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// add the indirect relation ids as a new relation field value
|
||||
for _, record := range records {
|
||||
relIds, ok := mappedIndirectRecordIds[record.Id]
|
||||
if ok && len(relIds) > 0 {
|
||||
record.Set(parts[0], relIds)
|
||||
}
|
||||
}
|
||||
|
||||
relFieldOptions = &schema.RelationOptions{
|
||||
MaxSelect: nil,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if indirectRelField.Unique {
|
||||
relFieldOptions.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
// indirect relation
|
||||
relField = &schema.SchemaField{
|
||||
Id: "indirect_" + security.PseudorandomString(5),
|
||||
Type: schema.FieldTypeRelation,
|
||||
Name: parts[0],
|
||||
Options: relFieldOptions,
|
||||
}
|
||||
relCollection = indirectRel
|
||||
} else {
|
||||
// direct relation
|
||||
relField = mainCollection.Schema.GetFieldByName(parts[0])
|
||||
if relField == nil || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
|
||||
if relFieldOptions == nil {
|
||||
return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
|
||||
}
|
||||
|
||||
relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
|
||||
if relCollection == nil {
|
||||
return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
|
||||
}
|
||||
}
|
||||
|
||||
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 := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
|
||||
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
|
||||
}
|
||||
|
||||
// fetch rels
|
||||
@@ -93,13 +178,13 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// reindex with the rel id
|
||||
indexedRels := map[string]*models.Record{}
|
||||
indexedRels := make(map[string]*models.Record, len(rels))
|
||||
for _, rel := range rels {
|
||||
indexedRels[rel.GetId()] = rel
|
||||
}
|
||||
|
||||
for _, model := range records {
|
||||
relIds := model.GetStringSliceDataValue(relField.Name)
|
||||
relIds := model.GetStringSlice(relField.Name)
|
||||
|
||||
validRels := make([]*models.Record, 0, len(relIds))
|
||||
for _, id := range relIds {
|
||||
@@ -112,7 +197,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue // no valid relations
|
||||
}
|
||||
|
||||
expandData := model.GetExpand()
|
||||
expandData := model.Expand()
|
||||
|
||||
// normalize access to the previously expanded rel records (if any)
|
||||
var oldExpandedRels []*models.Record
|
||||
@@ -133,17 +218,12 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue
|
||||
}
|
||||
|
||||
oldRelExpand := oldExpandedRel.GetExpand()
|
||||
newRelExpand := rel.GetExpand()
|
||||
for k, v := range oldRelExpand {
|
||||
newRelExpand[k] = v
|
||||
}
|
||||
rel.SetExpand(newRelExpand)
|
||||
rel.MergeExpand(oldExpandedRel.Expand())
|
||||
}
|
||||
}
|
||||
|
||||
// update the expanded data
|
||||
if relFieldOptions.MaxSelect == 1 {
|
||||
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
|
||||
expandData[relField.Name] = validRels[0]
|
||||
} else {
|
||||
expandData[relField.Name] = validRels
|
||||
|
||||
+187
-101
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
@@ -16,152 +17,189 @@ func TestExpandRecords(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordIds []string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty records
|
||||
{
|
||||
"empty records",
|
||||
"",
|
||||
[]string{},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"empty expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// missing relation field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// existing, but non-relation type field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid/missing second level expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"manyrels.invalid"},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
|
||||
"b84cd893-7119-43c9-8505-3c4e22da28a9",
|
||||
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
[]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)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
9,
|
||||
0,
|
||||
},
|
||||
// expand multiple relations sharing a common root path
|
||||
{
|
||||
"single expand",
|
||||
"users",
|
||||
[]string{
|
||||
"i15r5aa28ad06c8",
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel.onerel"},
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
4,
|
||||
0,
|
||||
},
|
||||
// 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)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
2,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand multiple relations sharing a common path",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_cascade",
|
||||
"self_rel_many.self_rel_one.rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
ids := list.ToUniqueStringSlice(s.recordIds)
|
||||
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
|
||||
records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
|
||||
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(records)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,109 +208,157 @@ func TestExpandRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordId string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"empty expand",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// invalid missing first level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid missing second level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.invalid"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
3,
|
||||
0,
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
8,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"no rels to expand",
|
||||
"users",
|
||||
"oap640cot4yru2s",
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
|
||||
for _, s := range scenarios {
|
||||
record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
|
||||
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(record)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// 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.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
newSchema := newCollection.Schema
|
||||
deletedFieldNames := []string{}
|
||||
renamedFieldNames := map[string]string{}
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.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
|
||||
}
|
||||
|
||||
deletedFieldNames = append(deletedFieldNames, oldField.Name)
|
||||
}
|
||||
|
||||
// check for new or renamed columns
|
||||
toRename := map[string]string{}
|
||||
for _, field := range newSchema.Fields() {
|
||||
oldField := oldSchema.GetFieldById(field.Id)
|
||||
// Note:
|
||||
// We are using a temporary column name when adding or renaming columns
|
||||
// to ensure that there are no name collisions in case there is
|
||||
// names switch/reuse of existing columns (eg. name, title -> title, name).
|
||||
// This way we are always doing 1 more rename operation but it provides better dev experience.
|
||||
|
||||
if oldField == nil {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
toRename[tempName] = field.Name
|
||||
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renamedFieldNames[oldField.Name] = field.Name
|
||||
}
|
||||
}
|
||||
|
||||
// set the actual columns name
|
||||
for tempName, actualName := range toRename {
|
||||
_, err := txDao.DB().RenameColumn(newTableName, tempName, actualName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
}
|
||||
|
||||
refs, err := dao.FindCollectionReferences(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for refCollection, refFields := range refs {
|
||||
for _, refField := range refFields {
|
||||
options, _ := refField.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove deleted (if any)
|
||||
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
|
||||
|
||||
for old, new := range renamedFieldNames {
|
||||
for i, name := range newDisplayFields {
|
||||
if name == old {
|
||||
newDisplayFields[i] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// has changes
|
||||
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
|
||||
options.DisplayFields = newDisplayFields
|
||||
|
||||
// direct collection save to prevent self-referencing
|
||||
// recursion and unnecessary records table sync checks
|
||||
if err := dao.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 TestSyncRecordTableSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").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
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+543
-195
@@ -1,22 +1,32 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
|
||||
@@ -30,30 +40,50 @@ 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
|
||||
collectionIdOrName string
|
||||
id string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 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 {
|
||||
{"demo2", "missing", nil, nil, true},
|
||||
{"missing", "0yxhwia2amd8gec", nil, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", nil, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "missing"})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": false})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "lorem"})
|
||||
{"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
}, false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
|
||||
record, err := app.Dao().FindRecordById(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.id,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -70,25 +100,34 @@ 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
|
||||
collectionIdOrName string
|
||||
ids []string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 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},
|
||||
{"demo2", []string{}, nil, nil, 0, false},
|
||||
{"demo2", []string{""}, nil, nil, 0, false},
|
||||
{"demo2", []string{"missing"}, nil, nil, 0, false},
|
||||
{"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
|
||||
{"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
nil,
|
||||
nil,
|
||||
2,
|
||||
false,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return nil // empty filter
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
},
|
||||
@@ -96,9 +135,25 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Like("title", "test").Match(true, true))
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
1,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
|
||||
return nil
|
||||
},
|
||||
1,
|
||||
@@ -107,7 +162,12 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
|
||||
records, err := app.Dao().FindRecordsByIds(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.ids,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -131,35 +191,53 @@ 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
|
||||
collectionIdOrName string
|
||||
expressions []dbx.Expression
|
||||
expectIds []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
nil,
|
||||
[]string{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
dbx.HashExp{"id": 123},
|
||||
"demo2",
|
||||
nil,
|
||||
[]string{
|
||||
"achvryl401bhse3",
|
||||
"llvuca81nly1qls",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
[]dbx.Expression{
|
||||
nil,
|
||||
dbx.HashExp{"id": "123"},
|
||||
},
|
||||
[]string{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
"sz5l5z67tg7gku0",
|
||||
[]dbx.Expression{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
dbx.HashExp{"active": true},
|
||||
},
|
||||
[]string{
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
|
||||
records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -183,42 +261,52 @@ 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
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
expectId string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
"id",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"invalid",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
"title",
|
||||
"lorem",
|
||||
"b5c2ffc2-bafd-48f7-b8b7-090638afe209",
|
||||
"test3",
|
||||
"0yxhwia2amd8gec",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
|
||||
record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -236,32 +324,44 @@ 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"
|
||||
testManyRelsId1 := "bgs820n361vj1qd"
|
||||
testManyRelsId2 := "4q1xlclmfloku33"
|
||||
testManyRelsId3 := "oap640cot4yru2s"
|
||||
|
||||
scenarios := []struct {
|
||||
key string
|
||||
value any
|
||||
excludeId string
|
||||
expected bool
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
excludeIds []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},
|
||||
{"demo2", "", "", nil, false},
|
||||
{"demo2", "", "", []string{""}, false},
|
||||
{"demo2", "missing", "unique", nil, false},
|
||||
{"demo2", "title", "unique", nil, true},
|
||||
{"demo2", "title", "unique", []string{}, true},
|
||||
{"demo2", "title", "unique", []string{""}, true},
|
||||
{"demo2", "title", "test1", []string{""}, false},
|
||||
{"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
|
||||
{"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
|
||||
// mixed json array order
|
||||
{"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
|
||||
// username special case-insensitive match
|
||||
{"users", "username", "test2_username", nil, false},
|
||||
{"users", "username", "TEST2_USERNAME", nil, false},
|
||||
{"users", "username", "new_username", nil, true},
|
||||
{"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
|
||||
result := app.Dao().IsRecordValueUnique(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.key,
|
||||
scenario.value,
|
||||
scenario.excludeIds...,
|
||||
)
|
||||
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
@@ -269,43 +369,164 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserRelatedRecords(t *testing.T) {
|
||||
func TestFindAuthRecordByToken(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
|
||||
token string
|
||||
baseKey string
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
{u0, []string{}},
|
||||
{u1, []string{
|
||||
"94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
|
||||
"fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
|
||||
}},
|
||||
{u2, []string{
|
||||
"b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
|
||||
}},
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base key (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token and base key but with deleted/missing collection
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindUserRelatedRecords(scenario.user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
|
||||
|
||||
if len(records) != len(scenario.expectedIds) {
|
||||
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
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)
|
||||
}
|
||||
if !scenario.expectError && record.Email() != scenario.expectedEmail {
|
||||
t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test@example.com", true},
|
||||
{"demo2", "test@example.com", true},
|
||||
{"users", "missing@example.com", true},
|
||||
{"users", "test@example.com", false},
|
||||
{"clients", "test2@example.com", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, 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 && record.Email() != scenario.email {
|
||||
t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
username string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test_username", true},
|
||||
{"demo2", "test_username", true},
|
||||
{"users", "missing", true},
|
||||
{"users", "test2_username", false},
|
||||
{"users", "TEST2_USERNAME", false}, // case insensitive check
|
||||
{"clients", "clients43362", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
|
||||
|
||||
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 && !strings.EqualFold(record.Username(), scenario.username) {
|
||||
t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
baseUsername string
|
||||
expectedPattern string
|
||||
}{
|
||||
// missing collection
|
||||
{"missing", "test2_username", `^test2_username\d{12}$`},
|
||||
// not an auth collection
|
||||
{"demo2", "test2_username", `^test2_username\d{12}$`},
|
||||
// auth collection with unique base username
|
||||
{"users", "new_username", `^new_username$`},
|
||||
{"users", "NEW_USERNAME", `^NEW_USERNAME$`},
|
||||
// auth collection with existing username
|
||||
{"users", "test2_username", `^test2_username\d{3}$`},
|
||||
{"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
username := app.Dao().SuggestUniqueAuthRecordUsername(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.baseUsername,
|
||||
)
|
||||
|
||||
pattern, err := regexp.Compile(scenario.expectedPattern)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
|
||||
}
|
||||
if !pattern.MatchString(username) {
|
||||
t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,32 +535,64 @@ func TestSaveRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
// create
|
||||
// ---
|
||||
r1 := models.NewRecord(collection)
|
||||
r1.SetDataValue("title", "test_new")
|
||||
r1.Set("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)
|
||||
newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
|
||||
if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
|
||||
t.Fatalf("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")
|
||||
r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
|
||||
r2.Set("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)
|
||||
newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
|
||||
if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
|
||||
t.Fatalf("Expected to find record %v, got %v", r2, newR2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
|
||||
|
||||
// base collection test
|
||||
r1 := models.NewRecord(baseCollection)
|
||||
r1.Set("title", "test_new")
|
||||
r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
|
||||
r1.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r1); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// auth collection test
|
||||
r2 := models.NewRecord(authCollection)
|
||||
r2.Set("username", "test_new")
|
||||
r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
|
||||
r2.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r2); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// try again with unique id
|
||||
r2.Set("id", "unique_id")
|
||||
if err := app.Dao().SaveRecord(r2); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,130 +600,225 @@ func TestDeleteRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
demo, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
demoCollection, _ := 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")
|
||||
rec0 := models.NewRecord(demoCollection)
|
||||
if err := app.Dao().DeleteRecord(rec0); err == nil {
|
||||
t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
|
||||
}
|
||||
|
||||
// delete existing record + external auths
|
||||
// ---
|
||||
rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err := app.Dao().DeleteRecord(rec1); err != nil {
|
||||
t.Fatalf("(rec1) Expected nil, got error %v", err)
|
||||
}
|
||||
// check if it was really deleted
|
||||
if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
|
||||
t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
|
||||
}
|
||||
// check if the external auths were deleted
|
||||
if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
|
||||
t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
|
||||
if err := app.Dao().DeleteRecord(rec2); err == nil {
|
||||
t.Fatalf("(rec2) Expected error, got nil")
|
||||
}
|
||||
|
||||
// delete existing record
|
||||
// delete existing record + cascade
|
||||
// ---
|
||||
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)
|
||||
calledQueries := []string{}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.Dao().ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
// delete
|
||||
if err := app.Dao().DeleteRecord(rec3); err != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// check if it was really deleted
|
||||
rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
|
||||
rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
|
||||
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")
|
||||
rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if rel != nil {
|
||||
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
|
||||
}
|
||||
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
if err := createMockBatchProcessingData(app.Dao()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
if err != nil {
|
||||
|
||||
// find and delete the first c1 record to trigger cascade
|
||||
mainRecord, _ := app.Dao().FindRecordById("c1", "a")
|
||||
if err := app.Dao().DeleteRecord(mainRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"},
|
||||
},
|
||||
// check if the main record was deleted
|
||||
_, err := app.Dao().FindRecordById(mainRecord.Collection().Id, mainRecord.Id)
|
||||
if err == nil {
|
||||
t.Fatal("The main record wasn't deleted")
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
continue
|
||||
}
|
||||
// check if the c1 b rel field were updated
|
||||
c1RecordB, err := app.Dao().FindRecordById("c1", "b")
|
||||
if err != nil || c1RecordB.GetString("rel") != "" {
|
||||
t.Fatalf("Expected c1RecordB.rel to be nil, got %v", c1RecordB.GetString("rel"))
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
// check if the c2 rel fields were updated
|
||||
c2Records, err := app.Dao().FindRecordsByExpr("c2", nil)
|
||||
if err != nil || len(c2Records) == 0 {
|
||||
t.Fatalf("Failed to fetch c2 records: %v", err)
|
||||
}
|
||||
for _, r := range c2Records {
|
||||
ids := r.GetStringSlice("rel")
|
||||
if len(ids) != 1 || ids[0] != "b" {
|
||||
t.Fatalf("Expected only 'b' rel id, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
// check if all c3 relations were deleted
|
||||
c3Records, err := app.Dao().FindRecordsByExpr("c3", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch c3 records: %v", err)
|
||||
}
|
||||
if total := len(c3Records); total != 0 {
|
||||
t.Fatalf("Expected c3 records to be deleted, found %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func createMockBatchProcessingData(dao *daos.Dao) error {
|
||||
// create mock collection without relation
|
||||
c1 := &models.Collection{}
|
||||
c1.Id = "c1"
|
||||
c1.Name = c1.Id
|
||||
c1.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "text",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
// self reference
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a multi-rel field
|
||||
c2 := &models.Collection{}
|
||||
c2.Id = "c2"
|
||||
c2.Name = c2.Id
|
||||
c2.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(10),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: false, // should unset all rel fields
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create mock collection with a single-rel field
|
||||
c3 := &models.Collection{}
|
||||
c3.Id = "c3"
|
||||
c3.Name = c3.Id
|
||||
c3.Schema = schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "rel",
|
||||
Type: schema.FieldTypeRelation,
|
||||
Options: &schema.RelationOptions{
|
||||
MaxSelect: types.Pointer(1),
|
||||
CollectionId: "c1",
|
||||
CascadeDelete: true, // should delete all c3 records
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := dao.SaveCollection(c3); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// insert mock records
|
||||
c1RecordA := models.NewRecord(c1)
|
||||
c1RecordA.Id = "a"
|
||||
c1RecordA.Set("rel", c1RecordA.Id) // self reference
|
||||
if err := dao.Save(c1RecordA); err != nil {
|
||||
return err
|
||||
}
|
||||
c1RecordB := models.NewRecord(c1)
|
||||
c1RecordB.Id = "b"
|
||||
c1RecordB.Set("rel", c1RecordA.Id) // rel to another record from the same collection
|
||||
if err := dao.Save(c1RecordB); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 4500; i++ {
|
||||
c2Record := models.NewRecord(c2)
|
||||
c2Record.Set("rel", []string{c1RecordA.Id, c1RecordB.Id})
|
||||
if err := dao.Save(c2Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// set the same id as the relation for at least 1 record
|
||||
// to check whether the correct condition will be added
|
||||
c3Record := models.NewRecord(c3)
|
||||
c3Record.Set("rel", c1RecordA.Id)
|
||||
c3Record.Id = c1RecordA.Id
|
||||
if err := dao.Save(c3Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
|
||||
|
||||
now := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
|
||||
@@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
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
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// FindSettings returns and decode the serialized app settings param value.
|
||||
//
|
||||
// The method will first try to decode the param value without decryption.
|
||||
// If it fails and optEncryptionKey is set, it will try again by first
|
||||
// decrypting the value and then decode it again.
|
||||
//
|
||||
// Returns an error if it fails to decode the stored serialized param value.
|
||||
func (dao *Dao) FindSettings(optEncryptionKey ...string) (*settings.Settings, error) {
|
||||
param, err := dao.FindParamByKey(models.ParamAppSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := settings.New()
|
||||
|
||||
// try first without decryption
|
||||
plainDecodeErr := json.Unmarshal(param.Value, result)
|
||||
|
||||
// failed, try to decrypt
|
||||
if plainDecodeErr != nil {
|
||||
var encryptionKey string
|
||||
if len(optEncryptionKey) > 0 && optEncryptionKey[0] != "" {
|
||||
encryptionKey = optEncryptionKey[0]
|
||||
}
|
||||
|
||||
// load without decrypt has failed and there is no encryption key to use for decrypt
|
||||
if encryptionKey == "" {
|
||||
return nil, 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 nil, decryptErr
|
||||
}
|
||||
|
||||
// decode again
|
||||
decryptedDecodeErr := json.Unmarshal(decrypted, result)
|
||||
if decryptedDecodeErr != nil {
|
||||
return nil, decryptedDecodeErr
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSettings persists the specified settings configuration.
|
||||
//
|
||||
// If optEncryptionKey is set, then the stored serialized value will be encrypted with it.
|
||||
func (dao *Dao) SaveSettings(newSettings *settings.Settings, optEncryptionKey ...string) error {
|
||||
return dao.SaveParam(models.ParamAppSettings, newSettings, optEncryptionKey...)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestSaveAndFindSettings(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
encryptionKey := security.PseudorandomString(32)
|
||||
|
||||
// change unencrypted app settings
|
||||
app.Settings().Meta.AppName = "save_unencrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if the change was persisted
|
||||
s1, err := app.Dao().FindSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings: %v", err)
|
||||
}
|
||||
if s1.Meta.AppName != "save_unencrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_unencrypted", s1)
|
||||
}
|
||||
|
||||
// make another change but this time provide an encryption key
|
||||
app.Settings().Meta.AppName = "save_encrypted"
|
||||
if err := app.Dao().SaveSettings(app.Settings(), encryptionKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try to fetch the settings without encryption key (should fail)
|
||||
if s2, err := app.Dao().FindSettings(); err == nil {
|
||||
t.Fatalf("Expected FindSettings to fail without an encryption key, got \n%v", s2)
|
||||
}
|
||||
|
||||
// try again but this time with an encryption key
|
||||
s3, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch settings with an encryption key %s: %v", encryptionKey, err)
|
||||
}
|
||||
if s3.Meta.AppName != "save_encrypted" {
|
||||
t.Fatalf("Expected settings to be changed with app name %q, got \n%v", "save_encrypted", s3)
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,11 @@ func (dao *Dao) DeleteTable(tableName string) error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Vacuum executes VACUUM on the current dao.DB() instance in order to
|
||||
// reclaim unused db disk space.
|
||||
func (dao *Dao) Vacuum() error {
|
||||
_, err := dao.DB().NewQuery("VACUUM").Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
@@ -79,3 +82,28 @@ func TestDeleteTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVacuum(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.DB().QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
if err := app.Dao().Vacuum(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(calledQueries); total != 1 {
|
||||
t.Fatalf("Expected 1 query, got %d", total)
|
||||
}
|
||||
|
||||
if calledQueries[0] != "VACUUM" {
|
||||
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
|
||||
}
|
||||
}
|
||||
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
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 := make([]string, len(users))
|
||||
usersMap := map[string]*models.User{}
|
||||
for i, user := range users {
|
||||
ids[i] = 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 non-empty 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.Not(dbx.HashExp{"email": ""})).
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
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
|
||||
}{
|
||||
{"", true},
|
||||
{"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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# ignore everything
|
||||
/*
|
||||
|
||||
# exclude from the ignore filter
|
||||
!.gitignore
|
||||
!main.go
|
||||
+59
-14
@@ -9,34 +9,70 @@ import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
)
|
||||
|
||||
func defaultPublicDir() string {
|
||||
if strings.HasPrefix(os.Args[0], os.TempDir()) {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
var publicDirFlag string
|
||||
// ---------------------------------------------------------------
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// add "--publicDir" option flag
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&publicDirFlag,
|
||||
&migrationsDir,
|
||||
"migrationsDir",
|
||||
"",
|
||||
"the directory with the user defined migrations",
|
||||
)
|
||||
|
||||
var automigrate bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&automigrate,
|
||||
"automigrate",
|
||||
true,
|
||||
"enable/disable auto migrations",
|
||||
)
|
||||
|
||||
var publicDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&publicDir,
|
||||
"publicDir",
|
||||
defaultPublicDir(),
|
||||
"the directory to serve static files",
|
||||
)
|
||||
|
||||
var indexFallback bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&indexFallback,
|
||||
"indexFallback",
|
||||
true,
|
||||
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
|
||||
)
|
||||
|
||||
app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// serves static files from the provided public dir (if exists)
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), false))
|
||||
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -44,3 +80,12 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// the default pb_public dir location is relative to the executable
|
||||
func defaultPublicDir() string {
|
||||
if strings.HasPrefix(os.Args[0], os.TempDir()) {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
+39
-40
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -10,72 +11,70 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminLogin specifies an admin email/pass login form.
|
||||
// AdminLogin is an admin email/pass login form.
|
||||
type AdminLogin struct {
|
||||
config AdminLoginConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// AdminLoginConfig is the [AdminLogin] factory initializer config.
|
||||
// NewAdminLogin creates a new [AdminLogin] form initialized with
|
||||
// the provided [core.App] instance.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminLoginConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminLogin creates a new [AdminLogin] form with initializer
|
||||
// config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminLoginWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminLogin(app core.App) *AdminLogin {
|
||||
return NewAdminLoginWithConfig(AdminLoginConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminLoginWithConfig creates a new [AdminLogin] form
|
||||
// with the provided config or panics on invalid configuration.
|
||||
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
|
||||
form := &AdminLogin{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// 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.EmailFormat),
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
|
||||
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) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *AdminLogin) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
admin, fetchErr := form.dao.FindAdminByEmail(form.Identity)
|
||||
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
if admin.ValidatePassword(form.Password) {
|
||||
return admin, nil
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
|
||||
if admin == nil || !admin.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
+50
-44
@@ -1,54 +1,15 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminLoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminLogin(nil)
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -62,14 +23,14 @@ func TestAdminLoginSubmit(t *testing.T) {
|
||||
{"", "", true},
|
||||
{"", "1234567890", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "1234567890", true},
|
||||
{"test", "test", 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.Identity = s.email
|
||||
form.Password = s.password
|
||||
|
||||
admin, err := form.Submit()
|
||||
@@ -88,3 +49,48 @@ func TestAdminLoginSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(testApp)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorAdmin *models.Admin
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorAdmin = admin
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorAdmin == nil || interceptorAdmin.Email != form.Identity {
|
||||
t.Fatalf("Expected Admin model with email %s, got %v", form.Identity, interceptorAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,55 +8,41 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
|
||||
// AdminPasswordResetConfirm is an admin password reset confirmation form.
|
||||
type AdminPasswordResetConfirm struct {
|
||||
config AdminPasswordResetConfirmConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetConfirmConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
||||
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
|
||||
form := &AdminPasswordResetConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the form Dao instance with the provided one.
|
||||
//
|
||||
// This is useful if you want to use a specific transaction Dao instance
|
||||
// instead of the default app.Dao().
|
||||
func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// 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.Password, validation.Required, validation.Length(10, 72)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
@@ -67,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByToken(
|
||||
v,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
|
||||
if err != nil || admin == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
@@ -80,14 +63,17 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
|
||||
// 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) {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Admin]) (*models.Admin, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByToken(
|
||||
admin, err := form.dao.FindAdminByToken(
|
||||
form.Token,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
form.app.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,8 +83,13 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.config.Dao.SaveAdmin(admin); err != nil {
|
||||
return nil, err
|
||||
interceptorsErr := runInterceptors(admin, func(m *models.Admin) error {
|
||||
admin = m
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return admin, nil
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetConfirm(nil)
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -38,16 +30,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
// valid with mismatched passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid with matching passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567891",
|
||||
"1234567891",
|
||||
false,
|
||||
},
|
||||
}
|
||||
@@ -57,59 +56,29 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
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)
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
admin, err := form.Submit(interceptor)
|
||||
|
||||
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()
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -128,3 +97,54 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(testApp)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc"
|
||||
form.Password = "1234567891"
|
||||
form.PasswordConfirm = "1234567891"
|
||||
interceptorTokenKey := admin.TokenKey
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorTokenKey = admin.TokenKey
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorTokenKey == admin.TokenKey {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,51 +9,35 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// AdminPasswordResetRequest specifies an admin password reset request form.
|
||||
// AdminPasswordResetRequest is an admin password reset request form.
|
||||
type AdminPasswordResetRequest struct {
|
||||
config AdminPasswordResetRequestConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetRequestConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
ResendThreshold float64 // in seconds
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetRequestWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
||||
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
|
||||
App: app,
|
||||
ResendThreshold: 120, // 2min
|
||||
})
|
||||
return &AdminPasswordResetRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
resendThreshold: 120, // 2min
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
|
||||
form := &AdminPasswordResetRequest{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -72,28 +56,33 @@ func (form *AdminPasswordResetRequest) Validate() error {
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` admin.
|
||||
func (form *AdminPasswordResetRequest) Submit() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := admin.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.config.Dao.SaveAdmin(admin)
|
||||
return runInterceptors(admin, func(m *models.Admin) error {
|
||||
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetRequest(nil)
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -70,7 +33,24 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
|
||||
adminBefore, _ := testApp.Dao().FindAdminByEmail(s.email)
|
||||
|
||||
err := form.Submit()
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
@@ -92,3 +72,52 @@ func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
admin, err := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
form.Email = admin.Email
|
||||
interceptorLastResetSentAt := admin.LastResetSentAt
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next(admin)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(admin *models.Admin) error {
|
||||
interceptorLastResetSentAt = admin.LastResetSentAt
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+20
-37
@@ -9,10 +9,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
|
||||
// AdminUpsert is a [models.Admin] upsert (create/update) form.
|
||||
type AdminUpsert struct {
|
||||
config AdminUpsertConfig
|
||||
admin *models.Admin
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
admin *models.Admin
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Avatar int `form:"avatar" json:"avatar"`
|
||||
@@ -21,41 +22,17 @@ type AdminUpsert struct {
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Admin] instances
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
||||
return NewAdminUpsertWithConfig(AdminUpsertConfig{
|
||||
App: app,
|
||||
}, admin)
|
||||
}
|
||||
|
||||
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
|
||||
// with the provided config and [models.Admin] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
|
||||
form := &AdminUpsert{
|
||||
config: config,
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.admin == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
// load defaults
|
||||
@@ -66,6 +43,11 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
@@ -75,6 +57,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
form.admin.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.admin.TableName())),
|
||||
).Else(validation.In(form.admin.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
@@ -92,7 +75,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.admin.IsNew(), validation.Required),
|
||||
validation.Length(10, 100),
|
||||
validation.Length(10, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
@@ -105,7 +88,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -116,7 +99,7 @@ func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc[*models.Admin]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,7 +117,7 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.admin.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.SaveAdmin(form.admin)
|
||||
return runInterceptors(form.admin, func(admin *models.Admin) error {
|
||||
return form.dao.SaveAdmin(admin)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
+16
-158
@@ -6,35 +6,11 @@ import (
|
||||
"fmt"
|
||||
"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 TestAdminUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestAdminUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -54,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -189,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update empty
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
@@ -225,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test2@example.com"
|
||||
}`,
|
||||
@@ -233,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567891"
|
||||
@@ -241,16 +99,16 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
// update succcess - new email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
// update success - new email
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test_update@example.com"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
{
|
||||
// update succcess - new password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
// update success - new password
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
@@ -279,10 +137,10 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -338,16 +196,16 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorAdminEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(m)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Admin]) forms.InterceptorNextFunc[*models.Admin] {
|
||||
return func(m *models.Admin) error {
|
||||
interceptorAdminEmail = admin.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
|
||||
+13
-6
@@ -2,23 +2,30 @@
|
||||
// validation and applying changes to existing DB models through the app Dao.
|
||||
package forms
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// base ID value regex pattern
|
||||
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
|
||||
|
||||
// InterceptorNextFunc is a interceptor handler function.
|
||||
// Usually used in combination with InterceptorFunc.
|
||||
type InterceptorNextFunc = func() error
|
||||
type InterceptorNextFunc[T any] func(t T) error
|
||||
|
||||
// InterceptorFunc defines a single interceptor function that will execute the provided next func handler.
|
||||
type InterceptorFunc func(next InterceptorNextFunc) InterceptorNextFunc
|
||||
// InterceptorFunc defines a single interceptor function that
|
||||
// will execute the provided next func handler.
|
||||
type InterceptorFunc[T any] func(next InterceptorNextFunc[T]) InterceptorNextFunc[T]
|
||||
|
||||
// runInterceptors executes the provided list of interceptors.
|
||||
func runInterceptors(next InterceptorNextFunc, interceptors ...InterceptorFunc) error {
|
||||
func runInterceptors[T any](
|
||||
data T,
|
||||
next InterceptorNextFunc[T],
|
||||
interceptors ...InterceptorFunc[T],
|
||||
) error {
|
||||
for i := len(interceptors) - 1; i >= 0; i-- {
|
||||
next = interceptors[i](next)
|
||||
}
|
||||
|
||||
return next()
|
||||
return next(data)
|
||||
}
|
||||
|
||||
+186
-52
@@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -8,20 +9,25 @@ import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"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/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
|
||||
|
||||
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
|
||||
// CollectionUpsert is a [models.Collection] upsert (create/update) form.
|
||||
type CollectionUpsert struct {
|
||||
config CollectionUpsertConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
@@ -30,47 +36,25 @@ type CollectionUpsert struct {
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
}
|
||||
|
||||
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Collection] instances
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
||||
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: app,
|
||||
}, collection)
|
||||
}
|
||||
|
||||
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
|
||||
// with the provided config and [models.Collection] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
|
||||
form := &CollectionUpsert{
|
||||
config: config,
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.collection == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Id = form.collection.Id
|
||||
form.Type = form.collection.Type
|
||||
form.Name = form.collection.Name
|
||||
form.System = form.collection.System
|
||||
form.ListRule = form.collection.ListRule
|
||||
@@ -78,6 +62,11 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
form.CreateRule = form.collection.CreateRule
|
||||
form.UpdateRule = form.collection.UpdateRule
|
||||
form.DeleteRule = form.collection.DeleteRule
|
||||
form.Options = form.collection.Options
|
||||
|
||||
if form.Type == "" {
|
||||
form.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
clone, _ := form.collection.Schema.Clone()
|
||||
if clone != nil {
|
||||
@@ -89,8 +78,15 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *CollectionUpsert) Validate() error {
|
||||
isAuth := form.Type == models.CollectionTypeAuth
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Id,
|
||||
@@ -98,12 +94,19 @@ func (form *CollectionUpsert) Validate() error {
|
||||
form.collection.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
validation.By(validators.UniqueId(form.dao, form.collection.TableName())),
|
||||
).Else(validation.In(form.collection.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
&form.System,
|
||||
validation.By(form.ensureNoSystemFlagChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Type,
|
||||
validation.Required,
|
||||
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
|
||||
validation.By(form.ensureNoTypeChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Required,
|
||||
@@ -115,26 +118,39 @@ func (form *CollectionUpsert) Validate() error {
|
||||
// validates using the type's own validation rules + some collection's specific
|
||||
validation.Field(
|
||||
&form.Schema,
|
||||
validation.By(form.checkMinSchemaFields),
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureExistingRelationCollectionId),
|
||||
validation.By(form.checkRelationFields),
|
||||
validation.When(
|
||||
isAuth,
|
||||
validation.By(form.ensureNoAuthFieldName),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
validation.Field(&form.Options, validation.By(form.checkOptions)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.config.Dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
// ensure unique collection name
|
||||
if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||
}
|
||||
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.Dao.HasTable(v) {
|
||||
// ensure that the collection name doesn't collide with the id of any collection
|
||||
if form.dao.FindById(&models.Collection{}, v) == nil {
|
||||
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
|
||||
}
|
||||
|
||||
// ensure that there is no existing table name with the same name
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
@@ -144,21 +160,31 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
|
||||
return nil
|
||||
if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
|
||||
return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
|
||||
v, _ := value.(bool)
|
||||
|
||||
if form.collection.IsNew() || v == form.collection.System {
|
||||
return nil
|
||||
if !form.collection.IsNew() && v != form.collection.System {
|
||||
return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.collection.IsNew() && v != form.collection.Type {
|
||||
return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
@@ -178,9 +204,17 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
|
||||
func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
systemDisplayFields := schema.BaseModelFieldNames()
|
||||
systemDisplayFields = append(systemDisplayFields,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameEmail,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameVerified,
|
||||
)
|
||||
|
||||
for i, field := range v.Fields() {
|
||||
if field.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
@@ -191,12 +225,76 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := form.config.Dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
)}
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
if form.Type != models.CollectionTypeAuth {
|
||||
return nil // not an auth collection
|
||||
}
|
||||
|
||||
authFieldNames := schema.AuthFieldNames()
|
||||
// exclude the meta RecordUpsert form fields
|
||||
authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
|
||||
|
||||
errs := validation.Errors{}
|
||||
for i, field := range v.Fields() {
|
||||
if list.ExistInSlice(field.Name, authFieldNames) {
|
||||
errs[fmt.Sprint(i)] = validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_reserved_auth_field_name",
|
||||
"The field name is reserved and cannot be used.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkMinSchemaFields(value any) error {
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
return nil // auth collections doesn't require having additional schema fields
|
||||
}
|
||||
|
||||
v, ok := value.(schema.Schema)
|
||||
if !ok || len(v.Fields()) == 0 {
|
||||
return validation.ErrRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -222,17 +320,49 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
|
||||
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.config.Dao, dummy, nil)
|
||||
dummy := *form.collection
|
||||
dummy.Type = form.Type
|
||||
dummy.Schema = form.Schema
|
||||
dummy.System = form.System
|
||||
dummy.Options = form.Options
|
||||
|
||||
r := resolvers.NewRecordFieldResolver(form.dao, &dummy, nil, true)
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_collection_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
v, _ := value.(types.JsonMap)
|
||||
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
raw, err := v.MarshalJSON()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
options := models.CollectionAuthOptions{}
|
||||
if err := json.Unmarshal(raw, &options); err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
// check the generic validations
|
||||
if err := options.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// additional form specific validations
|
||||
if err := form.checkRule(options.ManageRule); err != nil {
|
||||
return validation.Errors{"manageRule": err}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -244,12 +374,15 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if form.collection.IsNew() {
|
||||
// type can be set only on create
|
||||
form.collection.Type = form.Type
|
||||
|
||||
// system flag can be set only on create
|
||||
form.collection.System = form.System
|
||||
|
||||
@@ -271,8 +404,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.collection.CreateRule = form.CreateRule
|
||||
form.collection.UpdateRule = form.UpdateRule
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
form.collection.SetOptions(form.Options)
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.SaveCollection(form.collection)
|
||||
return runInterceptors(form.collection, func(collection *models.Collection) error {
|
||||
return form.dao.SaveCollection(collection)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
+154
-185
@@ -14,35 +14,13 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestCollectionUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestCollectionUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "test"
|
||||
collection.Name = "test_name"
|
||||
collection.Type = "test_type"
|
||||
collection.System = true
|
||||
listRule := "testview"
|
||||
collection.ListRule = &listRule
|
||||
@@ -65,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
@@ -104,95 +86,25 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
existingName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty create
|
||||
{"", "{}", []string{"name", "schema"}},
|
||||
// empty update
|
||||
{"demo", "{}", []string{}},
|
||||
// create failure
|
||||
{"empty create (base)", "", "{}", []string{"name", "schema"}},
|
||||
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
|
||||
{"empty update", "demo2", "{}", []string{}},
|
||||
{
|
||||
"create failure",
|
||||
"",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@@ -203,13 +115,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// create failure - existing name
|
||||
{
|
||||
"create failure - existing name",
|
||||
"",
|
||||
`{
|
||||
"name": "demo",
|
||||
"name": "demo1",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
@@ -222,19 +134,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - existing internal table
|
||||
{
|
||||
"create failure - existing internal table",
|
||||
"",
|
||||
`{
|
||||
"name": "_users",
|
||||
"name": "_admins",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - name starting with underscore
|
||||
{
|
||||
"create failure - name starting with underscore",
|
||||
"",
|
||||
`{
|
||||
"name": "_test_new",
|
||||
@@ -244,8 +156,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - duplicated field names (case insensitive)
|
||||
{
|
||||
"create failure - duplicated field names (case insensitive)",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
@@ -256,26 +168,67 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// create success
|
||||
{
|
||||
"create failure - missing relation display field",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name":"test",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"wsmn24bux7wo113",
|
||||
"displayFields":["text", "missing"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check type options validators",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"options": { "minPasswordLength": 3 }
|
||||
}`,
|
||||
[]string{"options"},
|
||||
},
|
||||
{
|
||||
"create success",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "auth",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"text"},
|
||||
{"id":"b123456","name":"test2","type":"email"}
|
||||
{"id":"b123456","name":"test2","type":"email"},
|
||||
{
|
||||
"name":"test3",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"v851q4r790rhknl",
|
||||
"displayFields":["name","id","created","updated","username","email","emailVisibility","verified"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"listRule": "test1='123'",
|
||||
"viewRule": "test1='123'",
|
||||
"createRule": "test1='123'",
|
||||
"updateRule": "test1='123'",
|
||||
"deleteRule": "test1='123'"
|
||||
"listRule": "test1='123' && verified = true",
|
||||
"viewRule": "test1='123' && emailVisibility = true",
|
||||
"createRule": "test1='123' && email != ''",
|
||||
"updateRule": "test1='123' && username != ''",
|
||||
"deleteRule": "test1='123' && id != ''"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - changing field type
|
||||
{
|
||||
"update failure - changing field type",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -285,8 +238,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update success - rename fields to existing field names (aka. reusing field names)
|
||||
{
|
||||
"update success - rename fields to existing field names (aka. reusing field names)",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -296,34 +249,43 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - existing name
|
||||
{
|
||||
"demo",
|
||||
`{"name": "demo2"}`,
|
||||
"update failure - existing name",
|
||||
"demo2",
|
||||
`{"name": "demo3"}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// update failure - changing system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update failure - changing system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"name": "update",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{"id":"koih1lqx","name":"userId","type":"text"}
|
||||
{"id":"koih1lqx","name":"abc","type":"text"}
|
||||
],
|
||||
"listRule": "userId = '123'",
|
||||
"viewRule": "userId = '123'",
|
||||
"createRule": "userId = '123'",
|
||||
"updateRule": "userId = '123'",
|
||||
"deleteRule": "userId = '123'"
|
||||
"listRule": "abc = '123'",
|
||||
"viewRule": "abc = '123'",
|
||||
"createRule": "abc = '123'",
|
||||
"updateRule": "abc = '123'",
|
||||
"deleteRule": "abc = '123'"
|
||||
}`,
|
||||
[]string{"name", "system", "schema"},
|
||||
[]string{"name", "system"},
|
||||
},
|
||||
// update failure - all fields
|
||||
{
|
||||
"demo",
|
||||
"update failure - changing collection type",
|
||||
"demo3",
|
||||
`{
|
||||
"type": "auth"
|
||||
}`,
|
||||
[]string{"type"},
|
||||
},
|
||||
{
|
||||
"update failure - all fields",
|
||||
"demo2",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@@ -332,29 +294,32 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
"deleteRule": "missing = '123'",
|
||||
"options": {"test": 123}
|
||||
}`,
|
||||
[]string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - update all fields
|
||||
{
|
||||
"demo",
|
||||
"update success - update all fields",
|
||||
"clients",
|
||||
`{
|
||||
"name": "demo_update",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
"listRule": "test='123' && verified = true",
|
||||
"viewRule": "test='123' && emailVisibility = true",
|
||||
"createRule": "test='123' && email != ''",
|
||||
"updateRule": "test='123' && username != ''",
|
||||
"deleteRule": "test='123' && id != ''",
|
||||
"options": {"minPasswordLength": 10}
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - rename the schema field of the last updated collection
|
||||
// (fail due to filters old field references)
|
||||
{
|
||||
"update failure - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -363,9 +328,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - rename the schema field of the last updated collection
|
||||
// (cleared filter references)
|
||||
{
|
||||
"update success - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@@ -379,21 +344,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update success - system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update success - system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"listRule": "userId='123'",
|
||||
"viewRule": "userId='123'",
|
||||
"createRule": "userId='123'",
|
||||
"updateRule": "userId='123'",
|
||||
"deleteRule": "userId='123'"
|
||||
"listRule": "name='123'",
|
||||
"viewRule": "name='123'",
|
||||
"createRule": "name='123'",
|
||||
"updateRule": "name='123'",
|
||||
"deleteRule": "name='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
@@ -408,15 +373,15 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,26 +389,26 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCall := 1
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCall = 0
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,42 +418,46 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name)
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System)
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, 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))
|
||||
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,7 +466,7 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -509,16 +478,16 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptorCollectionName := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCollectionName = collection.Name // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
@@ -547,14 +516,14 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newCollection := func() *models.Collection {
|
||||
return &models.Collection{
|
||||
Name: "c_" + security.RandomString(4),
|
||||
Name: "c_" + security.PseudorandomString(4),
|
||||
Schema: existingCollection.Schema,
|
||||
}
|
||||
}
|
||||
@@ -621,27 +590,27 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, scenario.collection)
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, s.collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
submitErr := form.Submit()
|
||||
hasErr := submitErr != nil
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && form.Id != "" {
|
||||
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-39
@@ -11,48 +11,31 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// CollectionsImport specifies a form model to bulk import
|
||||
// CollectionsImport is a form model to bulk import
|
||||
// (create, replace and delete) collections from a user provided list.
|
||||
type CollectionsImport struct {
|
||||
config CollectionsImportConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Collections []*models.Collection `form:"collections" json:"collections"`
|
||||
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
|
||||
}
|
||||
|
||||
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionsImportConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewCollectionsImport creates a new [CollectionsImport] form with
|
||||
// initializer config created from the provided [core.App] instance.
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionsImportWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionsImport(app core.App) *CollectionsImport {
|
||||
return NewCollectionsImportWithConfig(CollectionsImportConfig{
|
||||
App: app,
|
||||
})
|
||||
return &CollectionsImport{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCollectionsImportWithConfig creates a new [CollectionsImport]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
|
||||
form := &CollectionsImport{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionsImport) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@@ -73,15 +56,15 @@ func (form *CollectionsImport) Validate() error {
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.Collection]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
return runInterceptors(form.Collections, func(collections []*models.Collection) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
importErr := txDao.ImportCollections(
|
||||
form.Collections,
|
||||
collections,
|
||||
form.DeleteMissing,
|
||||
form.beforeRecordsSync,
|
||||
)
|
||||
@@ -95,7 +78,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.config.App.IsDebug() {
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
@@ -120,14 +103,14 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
if upsertModel == nil {
|
||||
upsertModel = collection
|
||||
}
|
||||
upsertModel.MarkAsNotNew()
|
||||
|
||||
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: form.config.App,
|
||||
Dao: txDao,
|
||||
}, upsertModel)
|
||||
upsertForm := NewCollectionUpsert(form.app, upsertModel)
|
||||
upsertForm.SetDao(txDao)
|
||||
|
||||
// load form fields with the refreshed collection state
|
||||
upsertForm.Id = collection.Id
|
||||
upsertForm.Type = collection.Type
|
||||
upsertForm.Name = collection.Name
|
||||
upsertForm.System = collection.System
|
||||
upsertForm.ListRule = collection.ListRule
|
||||
@@ -136,6 +119,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
upsertForm.UpdateRule = collection.UpdateRule
|
||||
upsertForm.DeleteRule = collection.DeleteRule
|
||||
upsertForm.Schema = collection.Schema
|
||||
upsertForm.Options = collection.Options
|
||||
|
||||
if err := upsertForm.Validate(); err != nil {
|
||||
// serialize the validation error(s)
|
||||
|
||||
@@ -10,16 +10,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestCollectionsImportPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionsImport(nil)
|
||||
}
|
||||
|
||||
func TestCollectionsImportValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@@ -62,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@@ -92,7 +82,26 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "test empty base collection schema",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"name": "import1"
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@@ -120,14 +129,18 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import3",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 11,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
"OnModelAfterCreate": 2,
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,7 +160,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@@ -158,8 +171,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"deleteMissing": true,
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -189,19 +202,22 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 8,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "modified + new collection",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
"name":"title",
|
||||
"name":"title_new",
|
||||
"type":"text",
|
||||
"system":false,
|
||||
"required":true,
|
||||
@@ -237,7 +253,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 10,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@@ -251,45 +267,44 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"deleteMissing": true,
|
||||
"collections": [
|
||||
{
|
||||
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"name":"profiles",
|
||||
"system":true,
|
||||
"listRule":"userId = @request.user.id",
|
||||
"viewRule":"created > 'test_change'",
|
||||
"createRule":"userId = @request.user.id",
|
||||
"updateRule":"userId = @request.user.id",
|
||||
"deleteRule":"userId = @request.user.id",
|
||||
"schema":[
|
||||
"id": "kpv709sk2lqbqk8",
|
||||
"system": true,
|
||||
"name": "nologin",
|
||||
"type": "auth",
|
||||
"options": {
|
||||
"allowEmailAuth": false,
|
||||
"allowOAuth2Auth": false,
|
||||
"allowUsernameAuth": false,
|
||||
"exceptEmailDomains": [],
|
||||
"manageRule": "@request.auth.collectionName = 'users'",
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": [],
|
||||
"requireEmail": true
|
||||
},
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": "",
|
||||
"updateRule": "",
|
||||
"deleteRule": "",
|
||||
"schema": [
|
||||
{
|
||||
"id":"koih1lqx",
|
||||
"name":"userId",
|
||||
"type":"user",
|
||||
"system":true,
|
||||
"required":true,
|
||||
"unique":true,
|
||||
"options":{
|
||||
"maxSelect":1,
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":false
|
||||
"id": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -308,7 +323,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"id": "test_deleted_collection_name_reuse",
|
||||
"name": "demo2",
|
||||
"name": "demo1",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
@@ -326,8 +341,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 3,
|
||||
"OnModelAfterDelete": 3,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelAfterDelete": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -389,16 +404,16 @@ func TestCollectionsImportSubmitInterceptors(t *testing.T) {
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
return next(imports)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// RecordEmailChangeConfirm is an auth record email change confirmation form.
|
||||
type RecordEmailChangeConfirm struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
|
||||
// initialized with from the provided [core.App] and [models.Collection] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
|
||||
return &RecordEmailChangeConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeConfirm) 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 *RecordEmailChangeConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, err := form.parseToken(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authRecord.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, _ := form.parseToken(form.Token)
|
||||
if authRecord == nil || !authRecord.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, 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.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, 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 signature is valid
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
token,
|
||||
form.app.Settings().RecordEmailChangeToken.Secret,
|
||||
)
|
||||
if err != nil || authRecord == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return authRecord, newEmail, nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the auth record email change confirmation form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, newEmail, err := form.parseToken(form.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"token", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"token": "", "password": ""}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// invalid token payload
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// existing new email
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// wrong confirmation password
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// 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(errs) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
|
||||
// check whether the user was updated
|
||||
// ---
|
||||
if record.Email() != newEmail {
|
||||
t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
|
||||
}
|
||||
|
||||
if !record.Verified() {
|
||||
t.Errorf("(%d) Expected record to be verified, got false", i)
|
||||
}
|
||||
|
||||
// shouldn't validate second time due to refreshed record token
|
||||
if err := form.Validate(); err == nil {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY"
|
||||
form.Password = "1234567890"
|
||||
interceptorEmail := authRecord.Email()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorEmail = record.Email()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorEmail == authRecord.Email() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RecordEmailChangeRequest is an auth record email change request form.
|
||||
type RecordEmailChangeRequest struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
|
||||
NewEmail string `form:"newEmail" json:"newEmail"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
|
||||
// initialized with from the provided [core.App] and [models.Record] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
|
||||
return &RecordEmailChangeRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.NewEmail,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
|
||||
return validation.NewError("validation_record_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordEmailChangeRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.record, func(m *models.Record) error {
|
||||
return mails.SendRecordChangeEmail(form.app, m, form.NewEmail)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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 TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail("users", "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": "test2@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.NewRecordEmailChangeRequest(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
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordEmailChangeRequest(testApp, authRecord)
|
||||
form.NewEmail = "test_new@example.com"
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// RecordOAuth2LoginData defines the OA
|
||||
type RecordOAuth2LoginData struct {
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
}
|
||||
|
||||
// BeforeOAuth2RecordCreateFunc defines a callback function that will
|
||||
// be called before OAuth2 new Record creation.
|
||||
type BeforeOAuth2RecordCreateFunc func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error
|
||||
|
||||
// RecordOAuth2Login is an auth record OAuth2 login form.
|
||||
type RecordOAuth2Login struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
beforeOAuth2RecordCreateFunc BeforeOAuth2RecordCreateFunc
|
||||
|
||||
// Optional auth record that will be used if no external
|
||||
// auth relation is found (if it is from the same collection)
|
||||
loggedAuthRecord *models.Record
|
||||
|
||||
// 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"`
|
||||
|
||||
// Additional data that will be used for creating a new auth record
|
||||
// if an existing OAuth2 account doesn't exist.
|
||||
CreateData map[string]any `form:"createData" json:"createData"`
|
||||
}
|
||||
|
||||
// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
|
||||
form := &RecordOAuth2Login{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
loggedAuthRecord: optAuthRecord,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// SetBeforeNewRecordCreateFunc sets a before OAuth2 record create callback handler.
|
||||
func (form *RecordOAuth2Login) SetBeforeNewRecordCreateFunc(f BeforeOAuth2RecordCreateFunc) {
|
||||
form.beforeOAuth2RecordCreateFunc = f
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordOAuth2Login) 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 *RecordOAuth2Login) 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.
|
||||
//
|
||||
// If an auth record doesn't exist, it will make an attempt to create it
|
||||
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
|
||||
// You can intercept/modify the Record create form with [form.SetBeforeNewRecordCreateFunc()].
|
||||
//
|
||||
// You can also optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
//
|
||||
// On success returns the authorized record model and the fetched provider's data.
|
||||
func (form *RecordOAuth2Login) Submit(
|
||||
interceptors ...InterceptorFunc[*RecordOAuth2LoginData],
|
||||
) (*models.Record, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !form.collection.AuthOptions().AllowOAuth2Auth {
|
||||
return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(form.Provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// load provider configuration
|
||||
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := providerConfig.SetupProvider(provider); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
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 external auth user
|
||||
authUser, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
if err != nil {
|
||||
return nil, authUser, err
|
||||
}
|
||||
case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
|
||||
// fallback to the logged auth record (if any)
|
||||
authRecord = form.loggedAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
}
|
||||
|
||||
interceptorData := &RecordOAuth2LoginData{
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
|
||||
return form.submit(newData)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorData.OAuth2User, interceptorsErr
|
||||
}
|
||||
|
||||
return interceptorData.Record, interceptorData.OAuth2User, nil
|
||||
}
|
||||
|
||||
func (form *RecordOAuth2Login) submit(data *RecordOAuth2LoginData) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if data.Record == nil {
|
||||
data.Record = models.NewRecord(form.collection)
|
||||
data.Record.RefreshId()
|
||||
data.Record.MarkAsNew()
|
||||
createForm := NewRecordUpsert(form.app, data.Record)
|
||||
createForm.SetFullManageAccess(true)
|
||||
createForm.SetDao(txDao)
|
||||
if data.OAuth2User.Username != "" &&
|
||||
len(data.OAuth2User.Username) >= 3 &&
|
||||
len(data.OAuth2User.Username) <= 150 &&
|
||||
usernameRegex.MatchString(data.OAuth2User.Username) {
|
||||
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(
|
||||
form.collection.Id,
|
||||
data.OAuth2User.Username,
|
||||
)
|
||||
}
|
||||
|
||||
// load custom data
|
||||
createForm.LoadData(form.CreateData)
|
||||
|
||||
// load the OAuth2 profile data as fallback
|
||||
if createForm.Email == "" {
|
||||
createForm.Email = data.OAuth2User.Email
|
||||
}
|
||||
createForm.Verified = false
|
||||
if createForm.Email == data.OAuth2User.Email {
|
||||
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
|
||||
createForm.Verified = true
|
||||
}
|
||||
if createForm.Password == "" {
|
||||
createForm.Password = security.RandomString(30)
|
||||
createForm.PasswordConfirm = createForm.Password
|
||||
}
|
||||
|
||||
if form.beforeOAuth2RecordCreateFunc != nil {
|
||||
if err := form.beforeOAuth2RecordCreateFunc(createForm, data.Record, data.OAuth2User); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// create the new auth record
|
||||
if err := createForm.Submit(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// update the existing auth record empty email if the data.OAuth2User has one
|
||||
// (this is in case previously the auth record was created
|
||||
// with an OAuth2 provider that didn't return an email address)
|
||||
if data.Record.Email() == "" && data.OAuth2User.Email != "" {
|
||||
data.Record.SetEmail(data.OAuth2User.Email)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update the existing auth record verified state
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
|
||||
if !data.Record.Verified() && (data.Record.Email() == "" || data.Record.Email() == data.OAuth2User.Email) {
|
||||
data.Record.SetVerified(true)
|
||||
if err := txDao.SaveRecord(data.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if data.ExternalAuth == nil {
|
||||
data.ExternalAuth = &models.ExternalAuth{
|
||||
CollectionId: data.Record.Collection().Id,
|
||||
RecordId: data.Record.Id,
|
||||
Provider: form.Provider,
|
||||
ProviderId: data.OAuth2User.Id,
|
||||
}
|
||||
if err := txDao.SaveExternalAuth(data.ExternalAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -9,55 +9,60 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserOauth2Login(nil)
|
||||
}
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
|
||||
// empty data
|
||||
{
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
// missing provider
|
||||
{
|
||||
"missing provider",
|
||||
"users",
|
||||
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// disabled provider
|
||||
{
|
||||
"disabled provider",
|
||||
"users",
|
||||
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// enabled provider
|
||||
{
|
||||
"enabled provider",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserOauth2Login(app)
|
||||
for _, s := range scenarios {
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if authCollection == nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection", s.testName)
|
||||
}
|
||||
|
||||
form := forms.NewRecordOAuth2Login(app, authCollection, nil)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -66,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, 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)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"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/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordLogin is record username/email + password login form.
|
||||
type RecordPasswordLogin struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
|
||||
// with from the provided [core.App] and [models.Collection] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
|
||||
return &RecordPasswordLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized record model.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to
|
||||
// further modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordLogin) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authOptions := form.collection.AuthOptions()
|
||||
|
||||
var authRecord *models.Record
|
||||
var fetchErr error
|
||||
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
|
||||
if isEmail {
|
||||
if authOptions.AllowEmailAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
|
||||
}
|
||||
} else if authOptions.AllowUsernameAuth {
|
||||
authRecord, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
|
||||
}
|
||||
|
||||
// ignore not found errors to allow custom fetch implementations
|
||||
if fetchErr != nil && !errors.Is(fetchErr, sql.ErrNoRows) {
|
||||
return nil, fetchErr
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
|
||||
if authRecord == nil || !authRecord.ValidatePassword(form.Password) {
|
||||
return errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
identity string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
|
||||
// username
|
||||
{
|
||||
"existing username + wrong password",
|
||||
"users",
|
||||
"users75657",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing username + valid password",
|
||||
"users",
|
||||
"clients57772", // not in the "users" collection
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"clients57772",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test_username",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password",
|
||||
"users",
|
||||
"users75657",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"existing email + wrong password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing email + valid password",
|
||||
"users",
|
||||
"test_missing@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing email + valid password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = s.identity
|
||||
form.Password = s.password
|
||||
|
||||
record, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Email() != s.identity && record.Username() != s.identity {
|
||||
t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordLoginInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = "test@example.com"
|
||||
form.Password = "123456"
|
||||
var interceptorRecord *models.Record
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorRecord = record
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorRecord == nil || interceptorRecord.Email() != form.Identity {
|
||||
t.Fatalf("Expected auth Record model with email %s, got %v", form.Identity, interceptorRecord)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordResetConfirm is an auth record password reset confirmation form.
|
||||
type RecordPasswordResetConfirm struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
|
||||
return &RecordPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordResetConfirm) Validate() error {
|
||||
minPasswordLength := form.collection.AuthOptions().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 *RecordPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
record, err := form.dao.FindAuthRecordByToken(
|
||||
v,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || record == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
if record.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetConfirm) Submit(interceptors ...InterceptorFunc[*models.Record]) (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
form.Token,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := authRecord.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
authRecord = m
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
|
||||
if interceptorsErr != nil {
|
||||
return nil, interceptorsErr
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid token but invalid passwords lengths
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"1234567",
|
||||
"passwordConfirm":"1234567"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid token but mismatched passwordConfirm
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345679"
|
||||
}`,
|
||||
[]string{"passwordConfirm"},
|
||||
},
|
||||
// valid token and password
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
record, submitErr := form.Submit(interceptor)
|
||||
|
||||
// parse errors
|
||||
errs, ok := submitErr.(validation.Errors)
|
||||
if !ok && submitErr != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// 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(errs) > 0 || len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenRecordId := claims["id"]
|
||||
|
||||
if record.Id != tokenRecordId {
|
||||
t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
|
||||
}
|
||||
|
||||
if !record.LastResetSentAt().IsZero() {
|
||||
t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
|
||||
}
|
||||
|
||||
if !record.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
form.Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = "1234567890"
|
||||
interceptorTokenKey := authRecord.TokenKey()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorTokenKey = record.TokenKey()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
_, submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorTokenKey == authRecord.TokenKey() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RecordPasswordResetRequest is an auth record reset password request form.
|
||||
type RecordPasswordResetRequest struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest {
|
||||
return &RecordPasswordResetRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
|
||||
func (form *RecordPasswordResetRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success, sends a password reset email to the `form.Email` auth record.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*models.Record]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := authRecord.LastResetSentAt().Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return runInterceptors(authRecord, func(m *models.Record) error {
|
||||
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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.NewRecordPasswordResetRequest(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(r *models.Record) error {
|
||||
interceptorCalls++
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor)
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%d] Expected interceptor to be called %d, got %d", i, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
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().FindAuthRecordByEmail(authCollection.Id, 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
|
||||
form.Email = authRecord.Email()
|
||||
interceptorLastResetSentAt := authRecord.LastResetSentAt()
|
||||
testErr := errors.New("test_error")
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptor1Called = true
|
||||
return next(record)
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
interceptorLastResetSentAt = record.LastResetSentAt()
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
submitErr := form.Submit(interceptor1, interceptor2)
|
||||
if submitErr != testErr {
|
||||
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
+617
-234
File diff suppressed because it is too large
Load Diff
+553
-256
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user