mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982f876a93 | ||
|
|
1fcc2d8683 | ||
|
|
4f2492290e | ||
|
|
8f625daa2f | ||
|
|
0599955676 | ||
|
|
c4116e3a7d | ||
|
|
64cee264f0 | ||
|
|
0ae9f24a81 | ||
|
|
6d942c7d30 | ||
|
|
9f67c5d563 | ||
|
|
1ac7330e0b | ||
|
|
461886f64e | ||
|
|
8d97eb0769 | ||
|
|
1208edec92 | ||
|
|
5555e63116 | ||
|
|
d6569b445c | ||
|
|
0b4f3b2adf | ||
|
|
8bd968ed06 | ||
|
|
5c961f8537 | ||
|
|
bf5eba0384 | ||
|
|
c213d9313e | ||
|
|
b31cf984a5 | ||
|
|
8671debc35 | ||
|
|
b0f027d27a | ||
|
|
98c8c98603 | ||
|
|
97345f0317 | ||
|
|
b29e404f22 | ||
|
|
d8ec36fa4c | ||
|
|
fb2eafe860 | ||
|
|
b9f391cf85 | ||
|
|
646f90ef43 | ||
|
|
5b6b4599b7 | ||
|
|
35fc6d0734 | ||
|
|
506b759560 | ||
|
|
d86e20b7f2 | ||
|
|
4c473385b2 | ||
|
|
afbbc1d97c | ||
|
|
4d3ba270c0 | ||
|
|
1bf7f148b0 | ||
|
|
6e6c873cc6 | ||
|
|
16da7d9e1a | ||
|
|
f7df737c45 | ||
|
|
64eefb44e8 | ||
|
|
31317df21c | ||
|
|
865865fdeb | ||
|
|
5b2575b754 | ||
|
|
6327ac20da | ||
|
|
8cd1c8709c | ||
|
|
14a2fd6215 | ||
|
|
cdfc1f7b70 | ||
|
|
41dcd9b4d4 | ||
|
|
0fb859c321 | ||
|
|
f57d38f529 | ||
|
|
04024cb6b7 | ||
|
|
58a2d3cd09 | ||
|
|
4d27278c60 | ||
|
|
70f1647a4c | ||
|
|
5b94aced3a | ||
|
|
070a1cd6d9 | ||
|
|
716f508d66 | ||
|
|
7013174315 | ||
|
|
559aad36a3 | ||
|
|
6416328c3b | ||
|
|
d3713a9d7c | ||
|
|
aaab643629 | ||
|
|
b283ee2263 | ||
|
|
995733000f | ||
|
|
99bdb4e701 | ||
|
|
3b79535dc7 | ||
|
|
05cc3f9e6c | ||
|
|
3f2e38ca82 | ||
|
|
531a7abec9 | ||
|
|
821aae4a62 | ||
|
|
ff5535f4de | ||
|
|
985ab1e5b7 | ||
|
|
69a805d0d1 | ||
|
|
d240649497 | ||
|
|
9957919d9a | ||
|
|
890a0904cf | ||
|
|
cdd32512d5 | ||
|
|
5835193900 | ||
|
|
4abe199acc | ||
|
|
a170923637 | ||
|
|
f4f3724b7a | ||
|
|
ba7cf8bf8e | ||
|
|
500615c1ee | ||
|
|
8961232a44 | ||
|
|
907167e696 | ||
|
|
4e51e393a2 | ||
|
|
5ea784609f | ||
|
|
ea5ca009de | ||
|
|
d13802133a | ||
|
|
f3a40001a4 | ||
|
|
1ae570921b | ||
|
|
f889a3fcb3 | ||
|
|
34fed679fd | ||
|
|
b7a49efa88 | ||
|
|
01e8c0f9f7 | ||
|
|
1d67a35acf | ||
|
|
e2d8028d0a | ||
|
|
d8a1875f84 | ||
|
|
79617e6d99 | ||
|
|
e6f1b3dfe4 | ||
|
|
94253f0dd5 | ||
|
|
6cfaf343ac | ||
|
|
9c562294ff | ||
|
|
3c5409d607 | ||
|
|
8868fa9ae6 | ||
|
|
c0fa53a2ab | ||
|
|
007b6a04ff | ||
|
|
731383a915 | ||
|
|
866d38caf9 | ||
|
|
1f6ab24b34 | ||
|
|
01e33c07fe | ||
|
|
69983bff5e | ||
|
|
2567659696 | ||
|
|
3e487f7e9d | ||
|
|
ca1a395628 | ||
|
|
1a47c70ccf | ||
|
|
1f4bdfb867 | ||
|
|
eae16cc42c | ||
|
|
1527b5ea4f | ||
|
|
ba6e17b3be | ||
|
|
b8219af941 | ||
|
|
8865cc1431 | ||
|
|
20b6ce4b84 | ||
|
|
e2f806d8bb | ||
|
|
49e3f4ad93 | ||
|
|
6d672348e7 | ||
|
|
80d774a8ef | ||
|
|
5a5125383a | ||
|
|
7fa1ff53c9 | ||
|
|
0f4e27a11f | ||
|
|
9997223923 | ||
|
|
632ade795f | ||
|
|
91bd739b71 | ||
|
|
957064d70b | ||
|
|
609792a355 | ||
|
|
2f5cfcfe87 | ||
|
|
5732bc38e3 | ||
|
|
d69181cfef | ||
|
|
8908d03b8c | ||
|
|
ebf73f5602 | ||
|
|
8416f03bcf | ||
|
|
5d87385170 | ||
|
|
837134559f | ||
|
|
fadd12cd22 | ||
|
|
469769d270 | ||
|
|
e4b7303a5d | ||
|
|
e1fb5d26a5 | ||
|
|
4f396ca439 | ||
|
|
ff08fc0fa4 | ||
|
|
4b511475ff | ||
|
|
2550a9de54 | ||
|
|
0f5dad7ede | ||
|
|
d0b1c9d998 | ||
|
|
fd9e120434 | ||
|
|
92731ddd50 | ||
|
|
4b4aaf2112 | ||
|
|
6013d14bc6 | ||
|
|
f3bcd7d3df | ||
|
|
71f9be3cb0 | ||
|
|
f605521208 | ||
|
|
4927583790 | ||
|
|
6e80cb8136 | ||
|
|
bb0a2dd698 | ||
|
|
2608efb56c | ||
|
|
eb2aa1cfc6 | ||
|
|
e1528aedac | ||
|
|
22b0a2b586 | ||
|
|
0ca86a0c87 | ||
|
|
b2c8f394af | ||
|
|
56b2641469 | ||
|
|
f266621a0f | ||
|
|
ca136c5dc1 |
@@ -16,12 +16,12 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 20.8.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.21.0'
|
||||
go-version: '>=1.21.3'
|
||||
|
||||
# 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.
|
||||
|
||||
+335
-1382
File diff suppressed because it is too large
Load Diff
+1384
File diff suppressed because it is too large
Load Diff
+21
-19
@@ -1,5 +1,4 @@
|
||||
Contributing to PocketBase
|
||||
======================================================================
|
||||
# Contributing to PocketBase
|
||||
|
||||
Thanks for taking the time to improve PocketBase!
|
||||
|
||||
@@ -9,26 +8,26 @@ This document describes how to prepare a PR for a change in the main repository.
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.18+ (for making changes in the Go code)
|
||||
- Go 1.19+ (for making changes in the Go code)
|
||||
- Node 16+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
```
|
||||
git clone https://github.com/your_username/pocketbase.git
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> It is recommended to create a new branch from master for each of your bugfixes and features.
|
||||
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
|
||||
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
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.
|
||||
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
|
||||
|
||||
@@ -41,20 +40,22 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
|
||||
|
||||
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
|
||||
To run the tests, you could execute (while in the root project directory):
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
|
||||
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
|
||||
@@ -65,14 +66,15 @@ To start the Admin UI:
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
3. Start vite's dev server
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Please keep in mind that PocketBase is still under active development
|
||||
> [!WARNING]
|
||||
> Please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
@@ -30,7 +30,6 @@ The easiest way to interact with the API is to use one of the official SDK clien
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
|
||||
@@ -39,10 +38,9 @@ your own custom app specific business logic and still have a single portable exe
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# go 1.18+
|
||||
# go 1.21+
|
||||
go get github.com/pocketbase/pocketbase
|
||||
```
|
||||
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
|
||||
|
||||
### Example
|
||||
|
||||
@@ -95,14 +93,15 @@ Enable CGO only if you really need to squeeze the read/write query performance a
|
||||
|
||||
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_)
|
||||
0. [Install Go 1.21+](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_)
|
||||
(_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
|
||||
@@ -114,6 +113,7 @@ linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
@@ -122,6 +122,7 @@ windows arm64
|
||||
|
||||
PocketBase comes with mixed bag of unit and integration tests.
|
||||
To run them, use the default `go test` command:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
@@ -134,7 +135,6 @@ If you discover a security vulnerability within PocketBase, please send an e-mai
|
||||
|
||||
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).
|
||||
@@ -154,5 +154,6 @@ PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I
|
||||
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._
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
|
||||
+2
-4
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -129,9 +128,8 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
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() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -119,6 +121,8 @@ func TestAdminAuthWithPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -188,6 +192,8 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -277,6 +283,8 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -350,6 +358,8 @@ func TestAdminRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -453,6 +463,8 @@ func TestAdminsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -508,6 +520,8 @@ func TestAdminView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -603,6 +617,8 @@ func TestAdminDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized (while having at least 1 existing admin)",
|
||||
@@ -757,6 +773,8 @@ func TestAdminCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
@@ -33,6 +35,8 @@ func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
@@ -66,6 +70,8 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewNotFoundError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -87,6 +93,8 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewBadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -108,6 +116,8 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewForbiddenError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -129,6 +139,8 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewUnauthorizedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
|
||||
+5
-6
@@ -2,7 +2,6 @@ package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -69,7 +68,7 @@ func (api *backupApi) list(c echo.Context) error {
|
||||
}
|
||||
|
||||
func (api *backupApi) create(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
|
||||
}
|
||||
|
||||
@@ -152,7 +151,7 @@ func (api *backupApi) download(c echo.Context) error {
|
||||
}
|
||||
|
||||
func (api *backupApi) restore(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
|
||||
}
|
||||
|
||||
@@ -181,8 +180,8 @@ func (api *backupApi) restore(c echo.Context) error {
|
||||
// give some optimistic time to write the response
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil {
|
||||
api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -203,7 +202,7 @@ func (api *backupApi) delete(c echo.Context) error {
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
|
||||
if key != "" && cast.ToString(api.app.Store().Get(core.StoreKeyActiveBackup)) == key {
|
||||
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -84,6 +86,8 @@ func TestBackupsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -116,7 +120,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
@@ -198,6 +202,8 @@ func TestBackupsCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsUpload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// create dummy form data bodies
|
||||
type body struct {
|
||||
buffer io.Reader
|
||||
@@ -330,6 +336,8 @@ func TestBackupsUpload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -485,6 +493,8 @@ func TestBackupsDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
@@ -562,7 +572,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// mock active backup with the same name to delete
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
@@ -583,7 +593,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// mock active backup with different name
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
@@ -645,6 +655,8 @@ func TestBackupsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBackupsRestore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -700,7 +712,7 @@ func TestBackupsRestore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
|
||||
+26
-29
@@ -6,11 +6,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
@@ -26,9 +27,9 @@ const trailedAdminPath = "/_/"
|
||||
// system and app specific routes and middlewares.
|
||||
func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
e.Debug = false
|
||||
e.JSONSerializer = &rest.Serializer{
|
||||
FieldsParam: "fields",
|
||||
FieldsParam: fieldsQueryParam,
|
||||
}
|
||||
|
||||
// configure a custom router
|
||||
@@ -49,6 +50,13 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e.Pre(LoadAuthContext(app))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set(ContextExecStartKey, time.Now())
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// custom error handler
|
||||
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
||||
@@ -56,30 +64,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return // no error
|
||||
}
|
||||
|
||||
if c.Response().Committed {
|
||||
if app.IsDebug() {
|
||||
log.Println("HTTPErrorHandler response was already committed:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var apiErr *ApiError
|
||||
|
||||
if errors.As(err, &apiErr) {
|
||||
if app.IsDebug() && apiErr.RawData() != nil {
|
||||
log.Println(apiErr.RawData())
|
||||
}
|
||||
// already an api error...
|
||||
} else if v := new(echo.HTTPError); errors.As(err, &v) {
|
||||
if v.Internal != nil && app.IsDebug() {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
} else {
|
||||
if app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
apiErr = NewNotFoundError("", err)
|
||||
} else {
|
||||
@@ -87,13 +79,19 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
logRequest(app, c, apiErr)
|
||||
|
||||
if c.Response().Committed {
|
||||
return // already commited
|
||||
}
|
||||
|
||||
event := new(core.ApiErrorEvent)
|
||||
event.HttpContext = c
|
||||
event.Error = apiErr
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
if c.Response().Committed {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,12 +104,11 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
})
|
||||
|
||||
if hookErr == nil {
|
||||
if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
if err := app.OnAfterApiError().Trigger(event); err != nil {
|
||||
app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
|
||||
}
|
||||
} else if app.IsDebug() {
|
||||
// truly rare case; eg. client already disconnected
|
||||
log.Println(hookErr)
|
||||
} else {
|
||||
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +212,7 @@ func updateHasAdminsCache(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Cache().Set(hasAdminsCacheKey, total > 0)
|
||||
app.Store().Set(hasAdminsCacheKey, total > 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -240,14 +237,14 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
|
||||
hasAdmins := cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
|
||||
if !hasAdmins {
|
||||
// update the cache to make sure that the admin wasn't created by another process
|
||||
if err := updateHasAdminsCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
|
||||
hasAdmins = cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
}
|
||||
|
||||
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func Test404(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
@@ -53,6 +55,8 @@ func Test404(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "custom route",
|
||||
@@ -142,6 +146,8 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "non /api/* route (exact match)",
|
||||
@@ -215,6 +221,8 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEagerRequestInfoCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "custom non-api group route",
|
||||
@@ -316,6 +324,8 @@ func TestEagerRequestInfoCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "apis.ApiError",
|
||||
|
||||
+14
-2
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCollectionsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -125,6 +127,8 @@ func TestCollectionsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -193,6 +197,8 @@ func TestCollectionView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
|
||||
storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
|
||||
|
||||
@@ -338,6 +344,8 @@ func TestCollectionDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -425,7 +433,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"type":"auth"`,
|
||||
`"system":false`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
@@ -715,6 +723,8 @@ func TestCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -1082,6 +1092,8 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -1141,7 +1153,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 4,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
|
||||
+68
-9
@@ -1,19 +1,26 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
@@ -21,7 +28,12 @@ var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
api := fileApi{
|
||||
app: app,
|
||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 1)), // the value is arbitrary chosen and may change in the future
|
||||
thumbGenPending: new(singleflight.Group),
|
||||
thumbGenMaxWait: 60 * time.Second,
|
||||
}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
subGroup.POST("/token", api.fileToken)
|
||||
@@ -31,6 +43,18 @@ func bindFileApi(app core.App, rg *echo.Group) {
|
||||
|
||||
type fileApi struct {
|
||||
app core.App
|
||||
|
||||
// thumbGenSem is a semaphore to prevent too much concurrent
|
||||
// requests generating new thumbs at the same time.
|
||||
thumbGenSem *semaphore.Weighted
|
||||
|
||||
// thumbGenPending represents a group of currently pending
|
||||
// thumb generation processes.
|
||||
thumbGenPending *singleflight.Group
|
||||
|
||||
// thumbGenMaxWait is the maximum waiting time for starting a new
|
||||
// thumb generation process.
|
||||
thumbGenMaxWait time.Duration
|
||||
}
|
||||
|
||||
func (api *fileApi) fileToken(c echo.Context) error {
|
||||
@@ -124,11 +148,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
baseFilesPath = fileRecord.BaseFilesPath()
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
fsys, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
defer fsys.Close()
|
||||
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
@@ -138,7 +162,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
thumbSize := c.QueryParam("thumb")
|
||||
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
|
||||
// extract the original file meta attributes and check it existence
|
||||
oAttrs, oAttrsErr := fs.Attributes(originalPath)
|
||||
oAttrs, oAttrsErr := fsys.Attributes(originalPath)
|
||||
if oAttrsErr != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
@@ -149,10 +173,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
|
||||
// create a new thumb if it doesn exists
|
||||
if exists, _ := fs.Exists(servedPath); !exists {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
|
||||
servedPath = originalPath // fallback to the original
|
||||
// create a new thumb if it doesn't exist
|
||||
if exists, _ := fsys.Exists(servedPath); !exists {
|
||||
if err := api.createThumb(c, fsys, originalPath, servedPath, thumbSize); err != nil {
|
||||
api.app.Logger().Warn(
|
||||
"Fallback to original - failed to create thumb "+servedName,
|
||||
slog.Any("error", err),
|
||||
slog.String("original", originalPath),
|
||||
slog.String("thumb", servedPath),
|
||||
)
|
||||
|
||||
// fallback to the original
|
||||
servedName = filename
|
||||
servedPath = originalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +209,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fs.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
|
||||
if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
@@ -214,3 +247,29 @@ func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.M
|
||||
|
||||
return nil, errors.New("missing or invalid file token")
|
||||
}
|
||||
|
||||
func (api *fileApi) createThumb(
|
||||
c echo.Context,
|
||||
fsys *filesystem.System,
|
||||
originalPath string,
|
||||
thumbPath string,
|
||||
thumbSize string,
|
||||
) error {
|
||||
ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), api.thumbGenMaxWait)
|
||||
defer cancel()
|
||||
|
||||
if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer api.thumbGenSem.Release(1)
|
||||
|
||||
return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
|
||||
})
|
||||
|
||||
res := <-ch
|
||||
|
||||
api.thumbGenPending.Forget(thumbPath)
|
||||
|
||||
return res.Err
|
||||
}
|
||||
|
||||
@@ -2,20 +2,26 @@ package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestFileToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -88,6 +94,8 @@ func TestFileToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFileDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
dataDirRelPath := "../tests/data/"
|
||||
|
||||
@@ -385,3 +393,82 @@ func TestFileDownload(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentThumbsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
// create a dummy file field collection
|
||||
demo1, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileField := demo1.Schema.GetFieldByName("file_one")
|
||||
fileField.Options = &schema.FileOptions{
|
||||
Protected: false,
|
||||
MaxSelect: 1,
|
||||
MaxSize: 999999,
|
||||
// new thumbs
|
||||
Thumbs: []string{"111x111", "111x222", "111x333"},
|
||||
}
|
||||
demo1.Schema.AddField(fileField)
|
||||
if err := app.Dao().SaveCollection(demo1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
|
||||
|
||||
e, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
urls := []string{
|
||||
"/api/files/" + fileKey + "?thumb=111x111",
|
||||
"/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
|
||||
"/api/files/" + fileKey + "?thumb=111x222",
|
||||
"/api/files/" + fileKey + "?thumb=111x333",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(urls))
|
||||
|
||||
for _, url := range urls {
|
||||
url := url
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
e.ServeHTTP(recorder, req)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// ensure that all new requested thumbs were created
|
||||
thumbKeys := []string{
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
|
||||
}
|
||||
for _, k := range thumbKeys {
|
||||
if exists, _ := fsys.Exists(k); !exists {
|
||||
t.Fatalf("Missing thumb %q: %v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
resp := new(healthCheckResponse)
|
||||
resp.Code = http.StatusOK
|
||||
resp.Message = "API is healthy."
|
||||
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
|
||||
resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
|
||||
+18
-18
@@ -15,27 +15,27 @@ func bindLogsApi(app core.App, rg *echo.Group) {
|
||||
api := logsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/logs", RequireAdminAuth())
|
||||
subGroup.GET("/requests", api.requestsList)
|
||||
subGroup.GET("/requests/stats", api.requestsStats)
|
||||
subGroup.GET("/requests/:id", api.requestView)
|
||||
subGroup.GET("", api.list)
|
||||
subGroup.GET("/stats", api.stats)
|
||||
subGroup.GET("/:id", api.view)
|
||||
}
|
||||
|
||||
type logsApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
var requestFilterFields = []string{
|
||||
var logFilterFields = []string{
|
||||
"rowid", "id", "created", "updated",
|
||||
"url", "method", "status", "auth",
|
||||
"remoteIp", "userIp", "referer", "userAgent",
|
||||
"level", "message", "data",
|
||||
`^data\.[\w\.\:]*\w+$`,
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsList(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
Query(api.app.LogsDao().LogQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Log{})
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
@@ -44,8 +44,8 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) stats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
filter := c.QueryParam(search.FilterQueryParam)
|
||||
|
||||
@@ -58,24 +58,24 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := api.app.LogsDao().RequestsStats(expr)
|
||||
stats, err := api.app.LogsDao().LogsStats(expr)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to generate requests stats.", err)
|
||||
return NewBadRequestError("Failed to generate logs stats.", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestView(c echo.Context) error {
|
||||
func (api *logsApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
request, err := api.app.LogsDao().FindRequestById(id)
|
||||
if err != nil || request == nil {
|
||||
log, err := api.app.LogsDao().FindLogById(id)
|
||||
if err != nil || log == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, request)
|
||||
return c.JSON(http.StatusOK, log)
|
||||
}
|
||||
|
||||
+27
-21
@@ -8,19 +8,21 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestsList(t *testing.T) {
|
||||
func TestLogsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -30,12 +32,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -52,12 +54,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests?filter=status>200",
|
||||
Url: "/api/logs?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -77,19 +79,21 @@ func TestRequestsList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestView(t *testing.T) {
|
||||
func TestLogView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -99,12 +103,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (nonexisting request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -114,12 +118,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (existing request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -135,19 +139,21 @@ func TestRequestView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -157,12 +163,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -174,12 +180,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats?filter=status>200",
|
||||
Url: "/api/logs/stats?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
|
||||
+81
-74
@@ -2,9 +2,10 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
ContextAdminKey string = "admin"
|
||||
ContextAuthRecordKey string = "authRecord"
|
||||
ContextCollectionKey string = "collection"
|
||||
ContextExecStartKey string = "execStart"
|
||||
)
|
||||
|
||||
// RequireGuestOnly middleware requires a request to NOT have a valid
|
||||
@@ -285,86 +286,92 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
|
||||
func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
|
||||
logsMaxDays := app.Settings().Logs.MaxDays
|
||||
|
||||
// no logs retention
|
||||
if logsMaxDays == 0 {
|
||||
if err := next(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
status := httpResponse.Status
|
||||
meta := types.JsonMap{}
|
||||
logRequest(app, c, nil)
|
||||
|
||||
if err != nil {
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case *ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
meta["errorMessage"] = v.Error()
|
||||
}
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
|
||||
model := &models.Request{
|
||||
Url: httpRequest.URL.RequestURI(),
|
||||
Method: strings.ToUpper(httpRequest.Method),
|
||||
Status: status,
|
||||
Auth: requestAuth,
|
||||
UserIp: realUserIp(httpRequest, ip),
|
||||
RemoteIp: ip,
|
||||
Referer: httpRequest.Referer(),
|
||||
UserAgent: httpRequest.UserAgent(),
|
||||
Meta: meta,
|
||||
}
|
||||
// set timestamp fields before firing a new go routine
|
||||
model.RefreshCreated()
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
// ---
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
|
||||
if daysDiff > float64(logsMaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*logsMaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Cache().Set("lastLogsDeletedAt", now)
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Logs delete failed:", deleteErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logRequest(app core.App, c echo.Context, err *ApiError) {
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := make([]any, 0, 15)
|
||||
|
||||
attrs = append(attrs, slog.String("type", "request"))
|
||||
|
||||
started := cast.ToTime(c.Get(ContextExecStartKey))
|
||||
if !started.IsZero() {
|
||||
attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
method := strings.ToUpper(httpRequest.Method)
|
||||
status := httpResponse.Status
|
||||
requestUri := httpRequest.URL.RequestURI()
|
||||
|
||||
// parse the request error
|
||||
if err != nil {
|
||||
status = err.Code
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("error", err.Message),
|
||||
slog.Any("details", err.RawData()),
|
||||
)
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("url", requestUri),
|
||||
slog.String("method", method),
|
||||
slog.Int("status", status),
|
||||
slog.String("auth", requestAuth),
|
||||
slog.String("referer", httpRequest.Referer()),
|
||||
slog.String("userAgent", httpRequest.UserAgent()),
|
||||
)
|
||||
|
||||
if app.Settings().Logs.LogIp {
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("userIp", realUserIp(httpRequest, ip)),
|
||||
slog.String("remoteIp", ip),
|
||||
)
|
||||
}
|
||||
|
||||
// don't block on logs write
|
||||
routine.FireAndForget(func() {
|
||||
message := method + " "
|
||||
|
||||
if escaped, err := url.PathUnescape(requestUri); err == nil {
|
||||
message += escaped
|
||||
} else {
|
||||
message += requestUri
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.Logger().Error(message, attrs...)
|
||||
} else {
|
||||
app.Logger().Info(message, attrs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
|
||||
//
|
||||
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRequireGuestOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "valid record token",
|
||||
@@ -104,6 +106,8 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -242,6 +246,8 @@ func TestRequireRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -358,6 +364,8 @@ func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -452,6 +460,8 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest (while having at least 1 existing admin)",
|
||||
@@ -571,6 +581,8 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -731,6 +743,8 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -869,6 +883,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadCollectionContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
|
||||
+225
-129
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,18 +15,20 @@ 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"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
api := realtimeApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/realtime", ActivityLogger(app))
|
||||
subGroup := rg.Group("/realtime")
|
||||
subGroup.GET("", api.connect)
|
||||
subGroup.POST("", api.setSubscriptions)
|
||||
subGroup.POST("", api.setSubscriptions, ActivityLogger(app))
|
||||
|
||||
api.bindEvents()
|
||||
}
|
||||
@@ -49,16 +51,19 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"OnRealtimeDisconnectRequest error",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}()
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
c.Response().Header().Set("Connection", "keep-alive")
|
||||
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
@@ -73,9 +78,7 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if api.app.IsDebug() {
|
||||
log.Printf("Realtime connection established: %s\n", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
@@ -97,9 +100,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver PB_CONNECT)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", connectMsgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -115,9 +120,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
case msg, ok := <-client.Channel():
|
||||
if !ok {
|
||||
// channel is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (closed channel):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (closed channel)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,9 +143,11 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver message)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", msgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,9 +155,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
idleTimer.Reset(idleTimeout)
|
||||
case <-c.Request().Context().Done():
|
||||
// connection is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (cancelled request):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (cancelled request)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -199,6 +208,12 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
// subscribe to the new subscriptions
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
api.app.Logger().Debug(
|
||||
"Realtime subscriptions updated.",
|
||||
slog.String("clientId", e.Client.Id()),
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
@@ -266,8 +281,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("create", record, false); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("create", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record create",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -275,8 +295,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("update", record, false); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("update", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record update",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -284,8 +309,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("delete", record, true); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("delete", record, true); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -293,8 +323,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastDryCachedRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastDryCachedRecord("delete", record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -327,58 +362,16 @@ func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection
|
||||
return collection
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// mock request data
|
||||
requestInfo := &models.RequestInfo{
|
||||
Method: "GET",
|
||||
}
|
||||
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
type recordData struct {
|
||||
Action string `json:"action"`
|
||||
Record *models.Record `json:"record"`
|
||||
Record any `json:"record"` /* map or models.Record */
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("Record collection not set.")
|
||||
return errors.New("[broadcastRecord] Record collection not set.")
|
||||
}
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
@@ -386,67 +379,125 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// 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,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(collection.Id + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(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,
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
// note: not executed concurrently to avoid races and to ensure
|
||||
// that the access checks are applied for the current record db state
|
||||
for prefix, rule := range subscriptionRuleMap {
|
||||
subs := client.Subscriptions(prefix)
|
||||
if len(subs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !api.canAccessRecord(client, data.Record, rule) {
|
||||
continue
|
||||
}
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
// 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 = newData
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
// mock request data
|
||||
requestInfo := &models.RequestInfo{
|
||||
Method: "GET",
|
||||
Query: options.Query,
|
||||
Headers: options.Headers,
|
||||
}
|
||||
}
|
||||
requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
|
||||
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
if dryCache {
|
||||
client.Set(action+"/"+data.Record.Id, msg)
|
||||
} else {
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
rawExpand := cast.ToString(options.Query[expandQueryParam])
|
||||
if rawExpand != "" {
|
||||
expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
|
||||
if len(expandErrs) > 0 {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] expand errors",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("expand", rawExpand),
|
||||
slog.Any("errors", expandErrs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ignore the auth record email visibility checks
|
||||
// for auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == cleanRecord.Id {
|
||||
if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
// check fields
|
||||
rawFields := cast.ToString(options.Query[fieldsQueryParam])
|
||||
if rawFields != "" {
|
||||
decoded, err := rest.PickFields(cleanRecord, rawFields)
|
||||
if err == nil {
|
||||
data.Record = decoded
|
||||
} else {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] pick fields error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("fields", rawFields),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] data marshal error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: sub,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
if dryCache {
|
||||
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
|
||||
if !ok {
|
||||
messages = []subscriptions.Message{msg}
|
||||
} else {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
client.Set(dryCacheKey, messages)
|
||||
} else {
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,14 +505,14 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dr
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastDryCachedRecord broadcasts record if it is cached in the client context.
|
||||
// broadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
|
||||
key := action + "/" + record.Id
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
|
||||
for _, client := range clients {
|
||||
key := action + "/" + record.Id
|
||||
|
||||
msg, ok := client.Get(key).(subscriptions.Message)
|
||||
messages, ok := client.Get(key).([]subscriptions.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -471,9 +522,12 @@ func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.R
|
||||
client := client
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
for _, msg := range messages {
|
||||
client.Send(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -494,3 +548,45 @@ func extractAuthIdFromGetter(val getter) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(
|
||||
record *models.Record,
|
||||
requestInfo *models.RequestInfo,
|
||||
accessRule *string,
|
||||
) bool {
|
||||
// check the access rule
|
||||
// ---
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, requestInfo, accessRule); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// check the subscription client-side filter (if any)
|
||||
// ---
|
||||
filter := cast.ToString(requestInfo.Query[search.FilterQueryParam])
|
||||
if filter == "" {
|
||||
return true // no further checks needed
|
||||
}
|
||||
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -22,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`id:`,
|
||||
@@ -44,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
@@ -68,6 +71,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
|
||||
+58
-36
@@ -4,8 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -73,12 +74,15 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
State string `json:"state"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
// technically could be omitted if the provider doesn't support PKCE,
|
||||
// but to avoid breaking existing typed clients we'll return them as empty string
|
||||
CodeVerifier string `json:"codeVerifier"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
@@ -90,12 +94,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
authOptions := collection.AuthOptions()
|
||||
|
||||
result := struct {
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
UsernamePassword bool `json:"usernamePassword"`
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
OnlyVerified bool `json:"onlyVerified"`
|
||||
}{
|
||||
UsernamePassword: authOptions.AllowUsernameAuth,
|
||||
EmailPassword: authOptions.AllowEmailAuth,
|
||||
OnlyVerified: authOptions.OnlyVerified,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
@@ -111,51 +117,61 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
|
||||
provider, err := auth.NewProviderByName(name)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug("Missing or invalid provier name", slog.String("name", name))
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Failed to setup provider",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(43)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
urlOpts := []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
info := providerInfo{
|
||||
Name: name,
|
||||
DisplayName: provider.DisplayName(),
|
||||
State: security.RandomString(30),
|
||||
}
|
||||
|
||||
if info.DisplayName == "" {
|
||||
info.DisplayName = name
|
||||
}
|
||||
|
||||
urlOpts := []oauth2.AuthCodeOption{}
|
||||
|
||||
// custom providers url options
|
||||
switch name {
|
||||
case auth.NameApple:
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
|
||||
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
|
||||
case auth.NameVK:
|
||||
// vk currently doesn't support PKCE for server-side authorization
|
||||
urlOpts = []oauth2.AuthCodeOption{}
|
||||
}
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthUrl: provider.BuildAuthUrl(
|
||||
state,
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
|
||||
})
|
||||
if provider.PKCE() {
|
||||
info.CodeVerifier = security.RandomString(43)
|
||||
info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
|
||||
info.CodeChallengeMethod = "S256"
|
||||
urlOpts = append(urlOpts,
|
||||
oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
|
||||
)
|
||||
}
|
||||
|
||||
info.AuthUrl = provider.BuildAuthUrl(
|
||||
info.State,
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=" // empty redirect_uri so that users can append their redirect url
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, info)
|
||||
}
|
||||
|
||||
// sort providers
|
||||
sort.SliceStable(result.AuthProviders, func(i, j int) bool {
|
||||
return result.AuthProviders[i].Name < result.AuthProviders[j].Name
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -327,8 +343,11 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
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)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send password reset email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -416,8 +435,11 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
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)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send verification email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+105
-2
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordAuthMethodsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -40,6 +42,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":true`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":false`,
|
||||
`"authProviders":[{`,
|
||||
`"name":"gitlab"`,
|
||||
`"state":`,
|
||||
@@ -58,6 +61,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":false`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":true`,
|
||||
`"authProviders":[]`,
|
||||
},
|
||||
},
|
||||
@@ -69,6 +73,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "invalid body format",
|
||||
@@ -212,7 +218,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and valid password in allowed collection",
|
||||
Name: "valid email (unverified) and valid password in allowed collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -225,6 +231,48 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":false`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// onlyVerified collection check
|
||||
{
|
||||
Name: "unverified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
@@ -312,6 +360,8 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -377,6 +427,41 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unverified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im8xeTBkZDBzcGQ3ODZtZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.-JYlrz5DcGzvb0nYx-xqnSFMu9dupyKY7Vg_FUm0OaM",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"record":`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"verified":true`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
@@ -404,6 +489,8 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -489,6 +576,8 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -602,6 +691,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -695,6 +786,8 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -823,6 +916,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -898,7 +993,7 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"newEmail":{"code":"validation_record_email_exists"`,
|
||||
`"newEmail":{"code":"validation_record_email_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -925,6 +1020,8 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -1045,6 +1142,8 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthListExternalsAuths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -1145,6 +1244,8 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -1274,6 +1375,8 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c1 := subscriptions.NewDefaultClient()
|
||||
|
||||
c2 := subscriptions.NewDefaultClient()
|
||||
|
||||
+28
-34
@@ -2,9 +2,8 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -16,8 +15,6 @@ import (
|
||||
"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) {
|
||||
@@ -45,13 +42,13 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestInfo.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
@@ -76,7 +73,7 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
@@ -90,8 +87,8 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil {
|
||||
api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -144,8 +141,13 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich view record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
@@ -237,8 +239,13 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
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)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich create record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
@@ -324,8 +331,13 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
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)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich update record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
@@ -396,21 +408,3 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordCrudList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -43,9 +45,16 @@ func TestRecordCrudList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only filter/sort (aka. @collection)",
|
||||
Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
|
||||
Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?sort=@request.auth.title",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
@@ -486,6 +495,8 @@ func TestRecordCrudList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -765,6 +776,8 @@ func TestRecordCrudView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
|
||||
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
|
||||
|
||||
@@ -1008,6 +1021,8 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "title_test",
|
||||
}, "files")
|
||||
@@ -1584,6 +1599,8 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "title_test",
|
||||
}, "files")
|
||||
|
||||
+40
-3
@@ -3,6 +3,7 @@ package apis
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -13,12 +14,16 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestInfoKey = "requestInfo"
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
const fieldsQueryParam = "fields"
|
||||
|
||||
// Deprecated: Use RequestInfo instead.
|
||||
func RequestData(c echo.Context) *models.RequestInfo {
|
||||
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
|
||||
@@ -49,7 +54,7 @@ func RequestInfo(c echo.Context) *models.RequestInfo {
|
||||
// ("X-Token" is converted to "x_token")
|
||||
for k, v := range c.Request().Header {
|
||||
if len(v) > 0 {
|
||||
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
|
||||
result.Headers[inflector.Snakecase(k)] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +77,10 @@ func RecordAuthResponse(
|
||||
meta any,
|
||||
finalizers ...func(token string) error,
|
||||
) error {
|
||||
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
|
||||
return NewForbiddenError("Please verify your email first.", nil)
|
||||
}
|
||||
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
@@ -104,8 +113,8 @@ func RecordAuthResponse(
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestInfo),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
if len(failed) > 0 {
|
||||
app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,3 +314,31 @@ func hasAuthManageAccess(
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var adminOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
// @todo consider moving the rules check to the RecordFieldResolver.
|
||||
//
|
||||
// checkForAdminOnlyRuleFields loosely checks and returns an error if
|
||||
// the provided RequestInfo contains rule fields that only the admin can use.
|
||||
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
|
||||
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
|
||||
return nil // admin or nothing to check
|
||||
}
|
||||
|
||||
for _, param := range ruleQueryParams {
|
||||
v, _ := requestInfo.Query[param].(string)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, field := range adminOnlyRuleFields {
|
||||
if strings.Contains(v, field) {
|
||||
return NewForbiddenError("Only admins can filter by "+field, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRequestInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
@@ -67,6 +69,8 @@ func TestRequestInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -83,6 +87,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
@@ -97,6 +106,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record but with unverified email in onlyVerified collection",
|
||||
record: unverfiedAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
@@ -179,6 +193,8 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnrichRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
|
||||
+39
-5
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -52,11 +53,11 @@ type ServeConfig struct {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// app.Bootstrap()
|
||||
// apis.Serve(app, apis.ServeConfig{
|
||||
// HttpAddr: "127.0.0.1:8080",
|
||||
// ShowStartBanner: false,
|
||||
// })
|
||||
// app.Bootstrap()
|
||||
// apis.Serve(app, apis.ServeConfig{
|
||||
// HttpAddr: "127.0.0.1:8080",
|
||||
// ShowStartBanner: false,
|
||||
// })
|
||||
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
if len(config.AllowedOrigins) == 0 {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
@@ -139,6 +140,11 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
HostPolicy: autocert.HostWhitelist(hostNames...),
|
||||
}
|
||||
|
||||
// base request context used for cancelling long running requests
|
||||
// like the SSE connections
|
||||
baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
|
||||
defer cancelBaseCtx()
|
||||
|
||||
server := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
@@ -150,6 +156,9 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return baseCtx
|
||||
},
|
||||
}
|
||||
|
||||
serveEvent := &core.ServeEvent{
|
||||
@@ -189,14 +198,39 @@ func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
|
||||
}
|
||||
|
||||
// WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
|
||||
// Note that the WaitGroup would not do anything if the app.OnTerminate() hook isn't triggered.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// try to gracefully shutdown the server on app termination
|
||||
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
||||
cancelBaseCtx()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
wg.Add(1)
|
||||
server.Shutdown(ctx)
|
||||
if e.IsRestart {
|
||||
// wait for execve and other handlers up to 5 seconds before exit
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
wg.Done()
|
||||
})
|
||||
} else {
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// wait for the graceful shutdown to complete before exit
|
||||
defer wg.Wait()
|
||||
|
||||
// ---
|
||||
// @todo consider removing the server return value because it is
|
||||
// not really useful when combined with the blocking serve calls
|
||||
// ---
|
||||
|
||||
// start HTTPS server
|
||||
if config.HttpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSettingsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -80,6 +82,8 @@ func TestSettingsList(t *testing.T) {
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -95,6 +99,8 @@ func TestSettingsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validData := `{"meta":{"appName":"update_test"}}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -161,6 +167,8 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -231,6 +239,8 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -272,6 +282,8 @@ func TestSettingsSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsTestS3(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -338,6 +350,8 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -502,6 +516,8 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminCreateCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+12
-6
@@ -5,6 +5,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -48,6 +49,9 @@ type App interface {
|
||||
// the users table from LogsDao will result in error.
|
||||
LogsDao() *daos.Dao
|
||||
|
||||
// Logger returns the active app logger.
|
||||
Logger() *slog.Logger
|
||||
|
||||
// DataDir returns the app data directory path.
|
||||
DataDir() string
|
||||
|
||||
@@ -55,16 +59,18 @@ type App interface {
|
||||
// (used for settings encryption).
|
||||
EncryptionEnv() string
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
IsDebug() bool
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
IsDev() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
Cache() *store.Store[any]
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
SubscriptionsBroker() *subscriptions.Broker
|
||||
|
||||
@@ -285,14 +291,14 @@ type App interface {
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// OnRealtimeBeforeMessageSend 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
|
||||
// OnRealtimeAfterMessageSend hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
|
||||
+174
-40
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -18,10 +19,14 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,8 +44,10 @@ var _ App = (*BaseApp)(nil)
|
||||
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
|
||||
|
||||
// configurable parameters
|
||||
isDebug bool
|
||||
isDev bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
dataMaxOpenConns int
|
||||
@@ -49,11 +56,12 @@ type BaseApp struct {
|
||||
logsMaxIdleConns int
|
||||
|
||||
// internals
|
||||
cache *store.Store[any]
|
||||
store *store.Store[any]
|
||||
settings *settings.Settings
|
||||
dao *daos.Dao
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
logger *slog.Logger
|
||||
|
||||
// app event hooks
|
||||
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
|
||||
@@ -167,9 +175,9 @@ type BaseApp struct {
|
||||
|
||||
// BaseAppConfig defines a BaseApp configuration option
|
||||
type BaseAppConfig struct {
|
||||
IsDev bool
|
||||
DataDir string
|
||||
EncryptionEnv string
|
||||
IsDebug bool
|
||||
DataMaxOpenConns int // default to 500
|
||||
DataMaxIdleConns int // default 20
|
||||
LogsMaxOpenConns int // default to 100
|
||||
@@ -182,14 +190,14 @@ type BaseAppConfig struct {
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
isDev: config.IsDev,
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
encryptionEnv: config.EncryptionEnv,
|
||||
dataMaxOpenConns: config.DataMaxOpenConns,
|
||||
dataMaxIdleConns: config.DataMaxIdleConns,
|
||||
logsMaxOpenConns: config.LogsMaxOpenConns,
|
||||
logsMaxIdleConns: config.LogsMaxIdleConns,
|
||||
cache: store.New[any](nil),
|
||||
store: store.New[any](nil),
|
||||
settings: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
|
||||
@@ -314,6 +322,17 @@ func (app *BaseApp) IsBootstrapped() bool {
|
||||
return app.dao != nil && app.logsDao != nil && app.settings != nil
|
||||
}
|
||||
|
||||
// Logger returns the default app logger.
|
||||
//
|
||||
// If the application is not bootstrapped yet, fallbacks to slog.Default().
|
||||
func (app *BaseApp) Logger() *slog.Logger {
|
||||
if app.logger == nil {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
return app.logger
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
@@ -343,6 +362,10 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.initLogger(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we don't check for an error because the db migrations may have not been executed yet
|
||||
app.RefreshSettings()
|
||||
|
||||
@@ -438,10 +461,11 @@ func (app *BaseApp) EncryptionEnv() string {
|
||||
return app.encryptionEnv
|
||||
}
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
func (app *BaseApp) IsDebug() bool {
|
||||
return app.isDebug
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
//
|
||||
// When enabled logs, executed sql statements, etc. are printed to the stderr.
|
||||
func (app *BaseApp) IsDev() bool {
|
||||
return app.isDev
|
||||
}
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
@@ -449,9 +473,15 @@ func (app *BaseApp) Settings() *settings.Settings {
|
||||
return app.settings
|
||||
}
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
func (app *BaseApp) Cache() *store.Store[any] {
|
||||
return app.cache
|
||||
color.Yellow("app.Store() is soft-deprecated. Please replace it with app.Store().")
|
||||
return app.Store()
|
||||
}
|
||||
|
||||
// Store returns the app internal runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
@@ -533,17 +563,17 @@ func (app *BaseApp) Restart() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// optimistically reset the app bootstrap state
|
||||
app.ResetBootstrapState()
|
||||
return app.OnTerminate().Trigger(&TerminateEvent{
|
||||
App: app,
|
||||
IsRestart: true,
|
||||
}, func(e *TerminateEvent) error {
|
||||
e.App.ResetBootstrapState()
|
||||
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
// restart the app bootstrap state
|
||||
app.Bootstrap()
|
||||
// attempt to restart the bootstrap process in case execve returns an error for some reason
|
||||
defer e.App.Bootstrap()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return syscall.Exec(execPath, os.Args, os.Environ())
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
@@ -555,7 +585,7 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
encryptionKey := os.Getenv(app.EncryptionEnv())
|
||||
|
||||
storedSettings, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -569,6 +599,13 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// reload handler level (if initialized and not in dev mode)
|
||||
if !app.IsDev() && app.Logger() != nil {
|
||||
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
|
||||
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -988,7 +1025,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
@@ -996,7 +1033,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
@@ -1019,7 +1056,7 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
@@ -1027,17 +1064,16 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
if app.IsDebug() {
|
||||
if app.IsDev() {
|
||||
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
|
||||
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
|
||||
}
|
||||
|
||||
@@ -1129,14 +1165,13 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
//
|
||||
// @todo consider creating a bg process queue so that the
|
||||
// call could be "retried" in case of a failure.
|
||||
routine.FireAndForget(func() {
|
||||
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
if err := deletePrefix(prefix); err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1144,12 +1179,111 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
app.ResetBootstrapState()
|
||||
if err := app.initAutobackupHooks(); err != nil {
|
||||
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BaseApp) initLogger() error {
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool)
|
||||
|
||||
// Apply the min level only if it is not in develop
|
||||
// to allow printing the logs to the console.
|
||||
//
|
||||
// DB logs are still filtered but the checks for the min level are done
|
||||
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
|
||||
var minLevel slog.Level
|
||||
if app.IsDev() {
|
||||
minLevel = -9999
|
||||
} else if app.Settings() != nil {
|
||||
minLevel = slog.Level(app.Settings().Logs.MinLevel)
|
||||
}
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: minLevel,
|
||||
BatchSize: 200,
|
||||
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
|
||||
if app.IsDev() {
|
||||
printLog(log)
|
||||
|
||||
// manually check the log level and skip if necessary
|
||||
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ticker.Reset(duration)
|
||||
|
||||
return app.Settings().Logs.MaxDays > 0
|
||||
},
|
||||
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
|
||||
app.LogsDao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
model := &models.Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
// note: using pseudorandom for a slightly better performance
|
||||
model.Id = security.PseudorandomStringWithAlphabet(models.DefaultIdLength, models.DefaultIdAlphabet)
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
model.Updated = model.Created
|
||||
|
||||
if err := txDao.SaveLog(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// delete old logs
|
||||
// ---
|
||||
logsMaxDays := app.Settings().Logs.MaxDays
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
if daysDiff > float64(logsMaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Store().Set("lastLogsDeletedAt", now)
|
||||
} else {
|
||||
log.Println("Logs delete failed", deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
handler.WriteAll(ctx)
|
||||
case <-ticker.C:
|
||||
handler.WriteAll(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
app.logger = slog.New(handler)
|
||||
|
||||
app.OnTerminate().PreAdd(func(e *TerminateEvent) error {
|
||||
ticker.Stop()
|
||||
done <- true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+40
-23
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -22,8 +22,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// Deprecated: Replaced with StoreKeyActiveBackup.
|
||||
const CacheKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
const StoreKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
@@ -43,7 +46,7 @@ const CacheKeyActiveBackup string = "@activeBackup"
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
@@ -51,8 +54,8 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
name = app.generateBackupName("pb_backup_")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
// root dir entries to exclude from the backup generation
|
||||
exclude := []string{LocalBackupsDirName, LocalTempDirName}
|
||||
@@ -135,12 +138,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return errors.New("restore is not supported on windows")
|
||||
}
|
||||
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
@@ -189,8 +192,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := os.Remove(tempZip.Name()); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// root dir entries to exclude from the backup restore
|
||||
@@ -223,8 +230,8 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// restart the app
|
||||
if err := app.Restart(); err != nil {
|
||||
if err := revertDataDirChanges(); err != nil {
|
||||
panic(err)
|
||||
if revertErr := revertDataDirChanges(); revertErr != nil {
|
||||
panic(revertErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restart the app process: %w", err)
|
||||
@@ -251,9 +258,12 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
|
||||
name := app.generateBackupName(autoPrefix)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to create backup",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
@@ -263,17 +273,21 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to initialize the backup filesystem",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to list autogenerated backups",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -290,9 +304,12 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err := fsys.Delete(f.Key); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to remove old autogenerated backup",
|
||||
slog.String("key", f.Key),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,11 +25,11 @@ func TestCreateBackup(t *testing.T) {
|
||||
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// create with auto generated name
|
||||
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
||||
@@ -98,11 +98,11 @@ func TestRestoreBackup(t *testing.T) {
|
||||
}
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// missing backup
|
||||
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
// 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")
|
||||
t.Fatalf("Failed to refresh the settings after delete: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+324
-36
@@ -1,10 +1,20 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/list"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
)
|
||||
|
||||
func TestNewBaseApp(t *testing.T) {
|
||||
@@ -14,7 +24,7 @@ func TestNewBaseApp(t *testing.T) {
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
IsDev: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
@@ -25,12 +35,12 @@ func TestNewBaseApp(t *testing.T) {
|
||||
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
|
||||
}
|
||||
|
||||
if !app.isDebug {
|
||||
t.Fatalf("expected isDebug true, got %v", app.isDebug)
|
||||
if !app.isDev {
|
||||
t.Fatalf("expected isDev true, got %v", app.isDev)
|
||||
}
|
||||
|
||||
if app.cache == nil {
|
||||
t.Fatal("expected cache to be set, got nil")
|
||||
if app.store == nil {
|
||||
t.Fatal("expected store to be set, got nil")
|
||||
}
|
||||
|
||||
if app.settings == nil {
|
||||
@@ -49,7 +59,6 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -57,7 +66,6 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,6 +114,14 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Expected app.settings to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if app.logger == nil {
|
||||
t.Fatal("Expected app.logger to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if _, ok := app.logger.Handler().(*logger.BatchHandler); !ok {
|
||||
t.Fatal("Expected app.logger handler to be initialized.")
|
||||
}
|
||||
|
||||
// reset
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -127,7 +143,7 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
IsDev: true,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -159,16 +175,20 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
|
||||
}
|
||||
|
||||
if app.isDebug != app.IsDebug() {
|
||||
t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug)
|
||||
if app.isDev != app.IsDev() {
|
||||
t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
|
||||
}
|
||||
|
||||
if app.settings != app.Settings() {
|
||||
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
|
||||
}
|
||||
|
||||
if app.cache != app.Cache() {
|
||||
t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache)
|
||||
if app.store != app.Store() {
|
||||
t.Fatalf("Expected app.Store %v, got %v", app.Store(), app.store)
|
||||
}
|
||||
|
||||
if app.logger != app.Logger() {
|
||||
t.Fatalf("Expected app.Logger %v, got %v", app.Logger(), app.logger)
|
||||
}
|
||||
|
||||
if app.subscriptionsBroker != app.SubscriptionsBroker() {
|
||||
@@ -181,14 +201,11 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -204,14 +221,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
@@ -234,14 +248,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
@@ -262,3 +273,280 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerWrites(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
threshold := 200
|
||||
|
||||
totalLogs := func(app App, t *testing.T) int {
|
||||
var total int
|
||||
|
||||
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// disabled logs retention
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
|
||||
for i := 0; i < threshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// test batch logs writes
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
|
||||
for i := 0; i < threshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
|
||||
// should trigger batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be added for the next batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
if total := totalLogs(app, t); total != threshold {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold, total)
|
||||
}
|
||||
|
||||
// wait for ~3 secs to check the timer trigger
|
||||
time.Sleep(3200 * time.Millisecond)
|
||||
if total := totalLogs(app, t); total != threshold+1 {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
level int
|
||||
// level->enabled map
|
||||
expectations map[int]bool
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: false,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app.isDev = s.isDev
|
||||
|
||||
app.Settings().Logs.MinLevel = s.level
|
||||
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh app settings: %v", err)
|
||||
}
|
||||
|
||||
for level, enabled := range s.expectations {
|
||||
if v := handler.Enabled(nil, slog.Level(level)); v != enabled {
|
||||
t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
testLogLevel := 4
|
||||
|
||||
app.Settings().Logs.MinLevel = testLogLevel
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
levels []int
|
||||
printedLevels []int
|
||||
persistedLevels []int
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
var printedLevels []int
|
||||
var persistedLevels []int
|
||||
|
||||
app.isDev = s.isDev
|
||||
|
||||
// trigger slog handler min level refresh
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// track printed logs
|
||||
originalPrintLog := printLog
|
||||
defer func() {
|
||||
printLog = originalPrintLog
|
||||
}()
|
||||
printLog = func(log *logger.Log) {
|
||||
printedLevels = append(printedLevels, int(log.Level))
|
||||
}
|
||||
|
||||
// track persisted logs
|
||||
app.LogsDao().AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
l, ok := m.(*models.Log)
|
||||
if ok {
|
||||
persistedLevels = append(persistedLevels, l.Level)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write and persist logs
|
||||
for _, l := range s.levels {
|
||||
app.Logger().Log(nil, slog.Level(l), "test")
|
||||
}
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
if err := handler.WriteAll(nil); err != nil {
|
||||
t.Fatalf("Failed to write all logs: %v", err)
|
||||
}
|
||||
|
||||
// check persisted log levels
|
||||
if len(s.persistedLevels) != len(persistedLevels) {
|
||||
t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
|
||||
}
|
||||
for _, l := range persistedLevels {
|
||||
if !list.ExistInSlice(l, s.persistedLevels) {
|
||||
t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
|
||||
}
|
||||
}
|
||||
|
||||
// check printed log levels
|
||||
if len(s.printedLevels) != len(printedLevels) {
|
||||
t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
|
||||
}
|
||||
for _, l := range printedLevels {
|
||||
if !list.ExistInSlice(l, s.printedLevels) {
|
||||
t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// note: make sure to call `defer cleanup()` when the app is no longer needed.
|
||||
func initTestBaseApp() (app *BaseApp, cleanup func(), err error) {
|
||||
testDataDir, err := os.MkdirTemp("", "test_base_app")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cleanup = func() {
|
||||
os.RemoveAll(testDataDir)
|
||||
}
|
||||
|
||||
app = NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
|
||||
initErr := func() error {
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
return fmt.Errorf("bootstrap error: %w", err)
|
||||
}
|
||||
|
||||
logsRunner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := logsRunner.Up(); err != nil {
|
||||
return fmt.Errorf("logsRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
dataRunner, err := migrate.NewRunner(app.DB(), migrations.AppMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := dataRunner.Up(); err != nil {
|
||||
return fmt.Errorf("dataRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if initErr != nil {
|
||||
cleanup()
|
||||
return nil, nil, initErr
|
||||
}
|
||||
|
||||
return app, cleanup, nil
|
||||
}
|
||||
|
||||
+2
-1
@@ -70,7 +70,8 @@ type BootstrapEvent struct {
|
||||
}
|
||||
|
||||
type TerminateEvent struct {
|
||||
App App
|
||||
App App
|
||||
IsRestart bool
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var cachedColors = store.New[*color.Color](nil)
|
||||
|
||||
// getColor returns [color.Color] object and cache it (if not already).
|
||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||
cacheKey := fmt.Sprint(attrs)
|
||||
if c = cachedColors.Get(cacheKey); c == nil {
|
||||
c = color.New(attrs...)
|
||||
cachedColors.Set(cacheKey, c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// printLog prints the provided log to the stderr.
|
||||
// (note: defined as variable to overwriting in the tests)
|
||||
var printLog = func(log *logger.Log) {
|
||||
var str strings.Builder
|
||||
|
||||
switch log.Level {
|
||||
case slog.LevelDebug:
|
||||
str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelInfo:
|
||||
str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelWarn:
|
||||
str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
|
||||
str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
|
||||
case slog.LevelError:
|
||||
str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
|
||||
str.WriteString(getColor(color.FgRed).Sprint(log.Message))
|
||||
default:
|
||||
str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
|
||||
str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
|
||||
}
|
||||
|
||||
str.WriteString("\n")
|
||||
|
||||
if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
|
||||
padding := 0
|
||||
keys := []string{"error", "details"}
|
||||
for _, k := range keys {
|
||||
if v := log.Data[k]; v != nil {
|
||||
str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
|
||||
str.WriteString("\n")
|
||||
padding += 3
|
||||
}
|
||||
}
|
||||
} else if len(log.Data) > 0 {
|
||||
str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
|
||||
str.WriteString("\n")
|
||||
}
|
||||
|
||||
fmt.Print(str.String())
|
||||
}
|
||||
+2
-2
@@ -46,9 +46,9 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindAdminByToken finds the admin associated with the provided JWT token.
|
||||
// FindAdminByToken finds the admin associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
// Returns an error if the JWT 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)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -20,6 +22,8 @@ func TestAdminQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -47,6 +51,8 @@ func TestFindAdminById(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -76,6 +82,8 @@ func TestFindAdminByEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -131,6 +139,8 @@ func TestFindAdminByToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTotalAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -155,6 +165,8 @@ func TestTotalAdmins(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAdminEmailUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -180,6 +192,8 @@ func TestIsAdminEmailUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -225,6 +239,8 @@ func TestDeleteAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+1
-8
@@ -6,7 +6,6 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -212,13 +211,7 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
// @todo after go 1.20+ upgrade consider replacing with errors.Join()
|
||||
var errsMsg strings.Builder
|
||||
for _, err := range errs {
|
||||
errsMsg.WriteString(err.Error())
|
||||
errsMsg.WriteString("; ")
|
||||
}
|
||||
return fmt.Errorf("after transaction errors: %s", errsMsg.String())
|
||||
return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+9
-1
@@ -2,6 +2,9 @@ package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,9 +26,14 @@ func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
|
||||
q.WithContext(cancelCtx)
|
||||
}
|
||||
|
||||
return baseLockRetry(func(attempt int) error {
|
||||
execErr := baseLockRetry(func(attempt int) error {
|
||||
return op()
|
||||
}, maxRetries)
|
||||
if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
|
||||
execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
|
||||
}
|
||||
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
)
|
||||
|
||||
func TestGetDefaultRetryInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
|
||||
t.Fatalf("Expected 1000ms, got %v", i)
|
||||
}
|
||||
@@ -20,6 +22,8 @@ func TestGetDefaultRetryInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseLockRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
err error
|
||||
failUntilAttempt int
|
||||
|
||||
@@ -402,6 +402,8 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
|
||||
// currently we don't support non-string model ids
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110).
|
||||
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
|
||||
query = strings.Trim(strings.TrimSpace(query), ";")
|
||||
|
||||
parsed, err := dao.parseQueryToFields(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCollectionQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -28,6 +30,8 @@ func TestCollectionQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionsByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -63,6 +67,8 @@ func TestFindCollectionsByType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -92,6 +98,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -116,6 +124,8 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -164,6 +174,8 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -251,6 +263,8 @@ func TestDeleteCollection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveCollectionCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -297,6 +311,8 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveCollectionUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -336,6 +352,8 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
|
||||
// indirect update of a field used in view should cause view(s) update
|
||||
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -395,6 +413,8 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveCollectionViewWrapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
viewName := "test_wrapping"
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -504,6 +524,8 @@ func TestSaveCollectionViewWrapping(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImportCollections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
|
||||
+18
-21
@@ -32,27 +32,6 @@ func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*mode
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByProvider returns the first available
|
||||
// ExternalAuth model for the specified provider and providerId.
|
||||
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(dbx.HashExp{
|
||||
"provider": provider,
|
||||
"providerId": providerId,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -74,6 +53,24 @@ func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, p
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindFirstExternalAuthByExpr returns the first available
|
||||
// ExternalAuth model that satisfies the non-nil expression.
|
||||
func (dao *Dao) FindFirstExternalAuthByExpr(expr dbx.Expression) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(expr).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// SaveExternalAuth upserts the provided ExternalAuth model.
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
|
||||
+26
-11
@@ -3,11 +3,14 @@ package daos_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestExternalAuthQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -20,6 +23,8 @@ func TestExternalAuthQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -56,25 +61,25 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
func TestFindFirstExternalAuthByExpr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
provider string
|
||||
providerId string
|
||||
expr dbx.Expression
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
{dbx.HashExp{"provider": "github", "providerId": ""}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id1"}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id2"}, ""},
|
||||
{dbx.HashExp{"provider": "google", "providerId": "test123"}, "clmflokuq1xl341"},
|
||||
{dbx.HashExp{"provider": "gitlab", "providerId": "test123"}, "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
|
||||
auth, err := app.Dao().FindFirstExternalAuthByExpr(s.expr)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
@@ -90,6 +95,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -126,6 +133,8 @@ func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -147,7 +156,11 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if it was really saved
|
||||
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
|
||||
foundAuth, err := app.Dao().FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": "v851q4r790rhknl",
|
||||
"provider": "test",
|
||||
"providerId": "test_id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -158,6 +171,8 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// LogQuery returns a new Log select query.
|
||||
func (dao *Dao) LogQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Log{})
|
||||
}
|
||||
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
func (dao *Dao) FindLogById(id string) (*models.Log, error) {
|
||||
model := &models.Log{}
|
||||
|
||||
err := dao.LogQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type LogsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// LogsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
result := []*LogsStatsItem{}
|
||||
|
||||
query := dao.LogQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldLogs(createdBefore time.Time) error {
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveLog upserts the provided Log model.
|
||||
func (dao *Dao) SaveLog(log *models.Log) error {
|
||||
return dao.Save(log)
|
||||
}
|
||||
@@ -11,23 +11,27 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRequestQuery(t *testing.T) {
|
||||
func TestLogQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_requests}}.* FROM `_requests`"
|
||||
expected := "SELECT {{_logs}}.* FROM `_logs`"
|
||||
|
||||
sql := app.Dao().RequestQuery().Build().SQL()
|
||||
sql := app.Dao().LogQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRequestById(t *testing.T) {
|
||||
func TestFindLogById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
@@ -40,7 +44,7 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
admin, err := app.LogsDao().FindRequestById(scenario.id)
|
||||
admin, err := app.LogsDao().FindLogById(scenario.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -53,17 +57,19 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
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})
|
||||
result, err := app.LogsDao().RequestsStats(exp)
|
||||
result, err := app.LogsDao().LogsStats(exp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,20 +80,22 @@ func TestRequestsStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldRequests(t *testing.T) {
|
||||
func TestDeleteOldLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
date string
|
||||
expectedTotal int
|
||||
}{
|
||||
{"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
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no logs to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more logs should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -96,53 +104,55 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
t.Errorf("(%d) Date error %v", i, dateErr)
|
||||
}
|
||||
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(date)
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(date)
|
||||
if deleteErr != nil {
|
||||
t.Errorf("(%d) Delete error %v", i, deleteErr)
|
||||
}
|
||||
|
||||
// check total remaining requests
|
||||
// check total remaining logs
|
||||
var total int
|
||||
countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total)
|
||||
countErr := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if countErr != nil {
|
||||
t.Errorf("(%d) Count error %v", i, countErr)
|
||||
}
|
||||
|
||||
if total != scenario.expectedTotal {
|
||||
t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total)
|
||||
t.Errorf("(%d) Expected %d remaining logs, got %d", i, scenario.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRequest(t *testing.T) {
|
||||
func TestSaveLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
// create new request
|
||||
newRequest := &models.Request{}
|
||||
newRequest.Method = "get"
|
||||
newRequest.Meta = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveRequest(newRequest)
|
||||
// create new log
|
||||
newLog := &models.Log{}
|
||||
newLog.Level = -4
|
||||
newLog.Data = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveLog(newLog)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
|
||||
// check if it was really created
|
||||
existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id)
|
||||
existingLog, fetchErr := app.LogsDao().FindLogById(newLog.Id)
|
||||
if fetchErr != nil {
|
||||
t.Fatal(fetchErr)
|
||||
}
|
||||
|
||||
existingRequest.Method = "post"
|
||||
updateErr := app.LogsDao().SaveRequest(existingRequest)
|
||||
existingLog.Level = 4
|
||||
updateErr := app.LogsDao().SaveLog(existingLog)
|
||||
if updateErr != nil {
|
||||
t.Fatal(updateErr)
|
||||
}
|
||||
// refresh instance to check if it was really updated
|
||||
existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id)
|
||||
if existingRequest.Method != "post" {
|
||||
t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method)
|
||||
existingLog, _ = app.LogsDao().FindLogById(existingLog.Id)
|
||||
if existingLog.Level != 4 {
|
||||
t.Fatalf("Expected log level to be %d, got %d", 4, existingLog.Level)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestParamQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -23,6 +25,8 @@ func TestParamQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindParamByKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -50,6 +54,8 @@ func TestFindParamByKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -92,6 +98,8 @@ func TestSaveParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveParamEncrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -126,6 +134,8 @@ func TestSaveParamEncrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+20
-5
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -409,9 +410,9 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
// Returns an error if the JWT 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 {
|
||||
@@ -660,9 +661,23 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
if refCollection.IsView() {
|
||||
continue // skip view collections
|
||||
// @todo consider changing refs to a slice
|
||||
//
|
||||
// Sort the refs keys to ensure that the cascade events firing order is always the same.
|
||||
// This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
|
||||
sortedRefKeys := make([]*models.Collection, 0, len(refs))
|
||||
for k := range refs {
|
||||
sortedRefKeys = append(sortedRefKeys, k)
|
||||
}
|
||||
sort.Slice(sortedRefKeys, func(i, j int) bool {
|
||||
return sortedRefKeys[i].Name < sortedRefKeys[j].Name
|
||||
})
|
||||
|
||||
for _, refCollection := range sortedRefKeys {
|
||||
fields, ok := refs[refCollection]
|
||||
|
||||
if refCollection.IsView() || !ok {
|
||||
continue // skip missing or view collections
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestExpandRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -222,6 +224,8 @@ func TestExpandRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExpandRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -372,6 +376,8 @@ func TestExpandRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -132,6 +134,8 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -58,6 +60,8 @@ func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordQueryOneWithRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -82,6 +86,8 @@ func TestRecordQueryOneWithRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -143,6 +149,8 @@ func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -203,6 +211,8 @@ func TestFindRecordById(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordsByIds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -294,6 +304,8 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordsByExpr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -364,6 +376,8 @@ func TestFindRecordsByExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -427,6 +441,8 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordsByFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -566,6 +582,8 @@ func TestFindRecordsByFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -647,6 +665,8 @@ func TestFindFirstRecordByFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCanAccessRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -814,6 +834,8 @@ func TestCanAccessRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsRecordValueUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -863,6 +885,8 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -925,6 +949,8 @@ func TestFindAuthRecordByToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -956,6 +982,8 @@ func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -988,6 +1016,8 @@ func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1025,6 +1055,8 @@ func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1058,6 +1090,8 @@ func TestSaveRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1090,6 +1124,8 @@ func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1167,6 +1203,8 @@ func TestDeleteRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RequestQuery returns a new Request logs select query.
|
||||
func (dao *Dao) RequestQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Request{})
|
||||
}
|
||||
|
||||
// FindRequestById finds a single Request log by its id.
|
||||
func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
|
||||
model := &models.Request{}
|
||||
|
||||
err := dao.RequestQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type RequestsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// RequestsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
|
||||
result := []*RequestsStatsItem{}
|
||||
|
||||
query := dao.RequestQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldRequests delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
|
||||
m := models.Request{}
|
||||
tableName := m.TableName()
|
||||
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveRequest upserts the provided Request model.
|
||||
func (dao *Dao) SaveRequest(request *models.Request) error {
|
||||
return dao.Save(request)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSaveAndFindSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestHasTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -36,6 +38,8 @@ func TestHasTable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTableColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -64,6 +68,8 @@ func TestTableColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTableInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -92,6 +98,8 @@ func TestTableInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -116,6 +124,8 @@ func TestDeleteTable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVacuum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -141,6 +151,8 @@ func TestVacuum(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTableIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+17
-7
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
trimmed := strings.Trim(selectQuery, ";")
|
||||
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
|
||||
// try to eagerly detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(trimmed)
|
||||
tk := tokenizer.NewFromString(selectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
@@ -56,7 +56,7 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
//
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution.
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
|
||||
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -232,6 +232,9 @@ func defaultViewField(name string) *schema.SchemaField {
|
||||
return &schema.SchemaField{
|
||||
Name: name,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 1, // the size doesn't matter in this case
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,7 +461,7 @@ type identifiersParser struct {
|
||||
}
|
||||
|
||||
func (p *identifiersParser) parse(selectQuery string) error {
|
||||
str := strings.Trim(selectQuery, ";")
|
||||
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
|
||||
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
|
||||
str = commentsReplaceRegex.ReplaceAllString(str, "")
|
||||
@@ -599,13 +602,20 @@ func identifierFromParts(parts []string) (identifier, error) {
|
||||
}
|
||||
|
||||
result.original = trimRawIdentifier(result.original)
|
||||
result.alias = trimRawIdentifier(result.alias)
|
||||
|
||||
// we trim the single quote even though it is not a valid column quote character
|
||||
// because SQLite allows it if the context expects an identifier and not string literal
|
||||
// (https://www.sqlite.org/lang_keywords.html)
|
||||
result.alias = trimRawIdentifier(result.alias, "'")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trimRawIdentifier(rawIdentifier string) string {
|
||||
const trimChars = "`\"[];"
|
||||
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
|
||||
trimChars := "`\"[];"
|
||||
if len(extraTrimChars) > 0 {
|
||||
trimChars += strings.Join(extraTrimChars, "")
|
||||
}
|
||||
|
||||
parts := strings.Split(rawIdentifier, ".")
|
||||
|
||||
|
||||
+51
-40
@@ -33,6 +33,8 @@ func ensureNoTempViews(app core.App, t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -60,6 +62,8 @@ func TestDeleteView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -147,40 +151,41 @@ func TestSaveView(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
t.Run(s.scenarioName, func(t *testing.T) {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -197,6 +202,8 @@ func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateViewSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -272,24 +279,26 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"datetime",
|
||||
"json",
|
||||
"rel_one",
|
||||
"rel_many"
|
||||
"rel_many",
|
||||
'single_quoted_custom_literal' as 'single_quoted_column'
|
||||
from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"single_quoted_column": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -485,6 +494,8 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func main() {
|
||||
// GitHub selfupdate
|
||||
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -51,6 +53,8 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminLoginInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -99,6 +101,8 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -66,7 +67,7 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -74,6 +76,8 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -31,6 +33,8 @@ func TestNewAdminUpsert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -183,6 +187,8 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -231,6 +237,8 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertWithCustomId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
|
||||
|
||||
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
|
||||
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
|
||||
//
|
||||
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
|
||||
type AppleClientSecretCreate struct {
|
||||
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
|
||||
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
|
||||
PrivateKey string `form:"privateKey" json:"privateKey"`
|
||||
|
||||
// Duration specifies how long the generated JWT token should be considered valid.
|
||||
// Duration specifies how long the generated JWT should be considered valid.
|
||||
// The specified value must be in seconds and max 15777000 (~6months).
|
||||
Duration int `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestBackupCreateValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
backupName string
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestBackupUploadValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var zb bytes.Buffer
|
||||
zw := zip.NewWriter(&zb)
|
||||
if err := zw.Close(); err != nil {
|
||||
|
||||
@@ -370,7 +370,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule. Raw error: "+err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -88,6 +90,8 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -654,6 +658,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -704,6 +710,8 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package forms
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -78,12 +77,9 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
"collections_import_failure",
|
||||
"Failed to import the collections configuration.",
|
||||
"Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
|
||||
)}
|
||||
})
|
||||
}, interceptors...)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCollectionsImportValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -38,6 +40,8 @@ func TestCollectionsImportValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
@@ -206,7 +210,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 4,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -418,48 +422,51 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Fatalf("Expected events %v, got %v", s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Fatalf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRealtimeSubscribeValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
clientId string
|
||||
expectError bool
|
||||
|
||||
@@ -128,6 +128,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*mo
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
|
||||
// @todo consider removing if not necessary anymore
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -145,6 +147,8 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ 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 validation.NewError("validation_record_email_invalid", "User email already exists or it is invalid.")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -106,6 +108,8 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -46,7 +46,7 @@ type RecordOAuth2Login struct {
|
||||
// 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.
|
||||
// The optional PKCE code verifier as part of the code_challenge sent with the initial request.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
@@ -88,8 +88,7 @@ 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),
|
||||
validation.Field(&form.RedirectUrl, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,11 +142,14 @@ func (form *RecordOAuth2Login) Submit(
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if provider.PKCE() {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
|
||||
}
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
token, err := provider.FetchToken(form.Code, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -161,7 +163,11 @@ func (form *RecordOAuth2Login) Submit(
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
rel, _ := form.dao.FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": form.collection.Id,
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -23,13 +25,13 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"missing provider",
|
||||
@@ -49,6 +51,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"[#3689] any redirectUrl value",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -132,6 +134,8 @@ func TestRecordPasswordLoginValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordPasswordLoginInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -136,6 +138,8 @@ func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -69,7 +70,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to fetch %s record with email %s: %w", form.collection.Id, form.Email, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -116,6 +118,8 @@ func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
+27
-20
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// username value regex pattern
|
||||
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
|
||||
var usernameRegex = regexp.MustCompile(`^[\w][\w\.\-]*$`)
|
||||
|
||||
// RecordUpsert is a [models.Record] upsert (create/update) form.
|
||||
type RecordUpsert struct {
|
||||
@@ -117,7 +117,7 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestInfo(
|
||||
func (form *RecordUpsert) extractRequestData(
|
||||
r *http.Request,
|
||||
keyPrefix string,
|
||||
) (map[string]any, map[string][]*filesystem.File, error) {
|
||||
@@ -200,8 +200,12 @@ func (form *RecordUpsert) extractMultipartFormData(
|
||||
|
||||
files, err := rest.FindUploadedFiles(r, fullKey)
|
||||
if err != nil || len(files) == 0 {
|
||||
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
|
||||
log.Printf("%q uploaded file error: %v\n", fullKey, err)
|
||||
if err != nil && err != http.ErrMissingFile {
|
||||
form.app.Logger().Debug(
|
||||
"Uploaded file error",
|
||||
slog.String("key", fullKey),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// skip invalid or missing file(s)
|
||||
@@ -219,12 +223,12 @@ func (form *RecordUpsert) extractMultipartFormData(
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
requestInfo, uploadedFiles, err := form.extractRequestInfo(r, keyPrefix)
|
||||
requestData, uploadedFiles, err := form.extractRequestData(r, keyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := form.LoadData(requestInfo); err != nil {
|
||||
if err := form.LoadData(requestData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -349,39 +353,39 @@ func (form *RecordUpsert) RemoveFiles(key string, toDelete ...string) error {
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided regular record data fields into the form.
|
||||
func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
|
||||
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
// load base system fields
|
||||
if v, ok := requestInfo[schema.FieldNameId]; ok {
|
||||
if v, ok := requestData[schema.FieldNameId]; ok {
|
||||
form.Id = cast.ToString(v)
|
||||
}
|
||||
|
||||
// load auth system fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
if v, ok := requestInfo[schema.FieldNameUsername]; ok {
|
||||
if v, ok := requestData[schema.FieldNameUsername]; ok {
|
||||
form.Username = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestInfo[schema.FieldNameEmail]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmail]; ok {
|
||||
form.Email = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestInfo[schema.FieldNameEmailVisibility]; ok {
|
||||
if v, ok := requestData[schema.FieldNameEmailVisibility]; ok {
|
||||
form.EmailVisibility = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestInfo[schema.FieldNameVerified]; ok {
|
||||
if v, ok := requestData[schema.FieldNameVerified]; ok {
|
||||
form.Verified = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestInfo["password"]; ok {
|
||||
if v, ok := requestData["password"]; ok {
|
||||
form.Password = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestInfo["passwordConfirm"]; ok {
|
||||
if v, ok := requestData["passwordConfirm"]; ok {
|
||||
form.PasswordConfirm = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestInfo["oldPassword"]; ok {
|
||||
if v, ok := requestData["oldPassword"]; ok {
|
||||
form.OldPassword = cast.ToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// replace modifiers (if any)
|
||||
requestInfo = form.record.ReplaceModifers(requestInfo)
|
||||
requestData = form.record.ReplaceModifers(requestData)
|
||||
|
||||
// create a shallow copy of form.data
|
||||
var extendedData = make(map[string]any, len(form.data))
|
||||
@@ -390,7 +394,7 @@ func (form *RecordUpsert) LoadData(requestInfo map[string]any) error {
|
||||
}
|
||||
|
||||
// extend form.data with the request data
|
||||
rawData, err := json.Marshal(requestInfo)
|
||||
rawData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -794,8 +798,11 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
//
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := form.processFilesToDelete(); err != nil {
|
||||
form.app.Logger().Debug(
|
||||
"Failed to delete old files",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -753,7 +753,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
"create with all allowed auth fields",
|
||||
"",
|
||||
map[string]any{
|
||||
"username": "test_new",
|
||||
"username": "test_new-a.b",
|
||||
"email": "test_new@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "12345678",
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -98,6 +100,8 @@ func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordVerificationConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -134,6 +136,8 @@ func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordVerificationRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
+16
-18
@@ -4,9 +4,12 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
|
||||
@@ -58,32 +61,27 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
|
||||
return runInterceptors(form.Settings, func(s *settings.Settings) error {
|
||||
form.Settings = s
|
||||
|
||||
oldSettings, err := form.app.Settings().Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// eagerly merge the application settings with the form ones
|
||||
if err := form.app.Settings().Merge(form.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// persists settings change
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
// try to revert app settings
|
||||
form.app.Settings().Merge(oldSettings)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
// reload app settings
|
||||
if err := form.app.RefreshSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to clear old logs not matching the new settings
|
||||
createdBefore := time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays).UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
|
||||
"date": createdBefore,
|
||||
"level": form.Settings.Logs.MinLevel,
|
||||
})
|
||||
form.app.LogsDao().NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
|
||||
|
||||
// no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
|
||||
if form.Settings.Logs.MaxDays == 0 {
|
||||
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
|
||||
form.app.LogsDao().Vacuum()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewSettingsUpsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -30,6 +32,8 @@ func TestNewSettingsUpsert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -127,6 +131,8 @@ func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestEmailSendValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
template string
|
||||
email string
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func TestS3FilesystemValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -66,6 +68,8 @@ func TestS3FilesystemValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestS3FilesystemSubmitFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestUploadedFileSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, mp, err := tests.MockMultipartData(nil, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -52,6 +54,8 @@ func TestUploadedFileSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadedFileMimeType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, mp, err := tests.MockMultipartData(nil, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestUniqueId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -132,11 +132,14 @@ func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField,
|
||||
|
||||
options, _ := field.Options.(*schema.TextOptions)
|
||||
|
||||
if options.Min != nil && len(val) < *options.Min {
|
||||
// note: casted to []rune to count multi-byte chars as one
|
||||
length := len([]rune(val))
|
||||
|
||||
if options.Min != nil && length < *options.Min {
|
||||
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
|
||||
}
|
||||
|
||||
if options.Max != nil && len(val) > *options.Max {
|
||||
if options.Max != nil && length > *options.Max {
|
||||
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
|
||||
}
|
||||
|
||||
@@ -298,8 +301,14 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
raw, _ := types.ParseJsonRaw(value)
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
|
||||
options, _ := field.Options.(*schema.JsonOptions)
|
||||
|
||||
if len(raw) > options.MaxSize {
|
||||
return validation.NewError("validation_json_size_limit", fmt.Sprintf("The maximum allowed JSON size is %v bytes", options.MaxSize))
|
||||
}
|
||||
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
|
||||
return requiredErr
|
||||
}
|
||||
@@ -376,7 +385,7 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
|
||||
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
|
||||
Row(&total)
|
||||
if total != len(ids) {
|
||||
return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids")
|
||||
return validation.NewError("validation_missing_rel_records", "Failed to find all relation records with the provided ids")
|
||||
}
|
||||
// ---
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -63,15 +65,17 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
Name: "field2",
|
||||
Required: true,
|
||||
Type: schema.FieldTypeText,
|
||||
Options: &schema.TextOptions{
|
||||
Pattern: pattern,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Unique: true,
|
||||
Type: schema.FieldTypeText,
|
||||
Options: &schema.TextOptions{
|
||||
Min: &min,
|
||||
Max: &max,
|
||||
Pattern: pattern,
|
||||
Min: &min,
|
||||
Max: &max,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -109,6 +113,16 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check min constraint with multi-bytes char",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
"field3": "𝌆", // 4 bytes should be counted as 1 char
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check max constraint",
|
||||
map[string]any{
|
||||
@@ -119,15 +133,25 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check max constraint with multi-bytes chars",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
"field3": strings.Repeat("𝌆", max), // shouldn't exceed the max limit even though max*4bytes chars are used
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(text) check pattern constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": "test",
|
||||
"field3": "test!",
|
||||
"field2": "test!",
|
||||
"field3": "test",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(text) valid data (only required)",
|
||||
@@ -840,16 +864,25 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 10,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Required: true,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 9999,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Unique: true,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 9999,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
@@ -916,6 +949,15 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(json) check MaxSize constraint",
|
||||
map[string]any{
|
||||
"field1": `"123456789"`, // max 10bytes
|
||||
"field2": 123,
|
||||
},
|
||||
nil,
|
||||
[]string{"field1"},
|
||||
},
|
||||
{
|
||||
"(json) check json text invalid obj, array and number normalizations",
|
||||
map[string]any{
|
||||
@@ -947,9 +989,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
{
|
||||
"(json) valid data - all fields with normalizations",
|
||||
map[string]any{
|
||||
"field1": []string{"a", "b", "c"},
|
||||
"field1": `"12345678"`,
|
||||
"field2": 123,
|
||||
"field3": `"test"`,
|
||||
"field3": []string{"a", "b", "c"},
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
@@ -1251,29 +1293,30 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
|
||||
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
|
||||
for i, s := range scenarios {
|
||||
validator := validators.NewRecordDataValidator(dao, record, s.files)
|
||||
result := validator.Validate(s.data)
|
||||
|
||||
prefix := fmt.Sprintf("%d", i)
|
||||
if s.name != "" {
|
||||
prefix = s.name
|
||||
prefix := s.name
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", prefix, result)
|
||||
continue
|
||||
}
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
validator := validators.NewRecordDataValidator(dao, record, s.files)
|
||||
result := validator.Validate(s.data)
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
valA string
|
||||
valB string
|
||||
|
||||
@@ -1,94 +1,95 @@
|
||||
module github.com/pocketbase/pocketbase
|
||||
|
||||
go 1.18
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/aws/aws-sdk-go v1.44.333
|
||||
github.com/aws/aws-sdk-go v1.48.16
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||
github.com/dop251/goja v0.0.0-20230707174833-636fdf960de1
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
|
||||
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/ganigeorgiev/fexpr v0.4.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/goccy/go-json v0.10.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pocketbase/tygoja v0.0.0-20230618203136-2f8d57768be1
|
||||
github.com/spf13/cast v1.5.1
|
||||
github.com/spf13/cobra v1.7.0
|
||||
gocloud.dev v0.34.0
|
||||
golang.org/x/crypto v0.12.0
|
||||
golang.org/x/net v0.14.0
|
||||
golang.org/x/oauth2 v0.11.0
|
||||
golang.org/x/sync v0.3.0
|
||||
modernc.org/sqlite v1.25.0
|
||||
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
gocloud.dev v0.35.0
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/net v0.19.0
|
||||
golang.org/x/oauth2 v0.15.0
|
||||
golang.org/x/sync v0.5.0
|
||||
modernc.org/sqlite v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.21.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.81 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.42 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.21.5 // indirect
|
||||
github.com/aws/smithy-go v1.14.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
|
||||
github.com/aws/smithy-go v1.19.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230705174524-200ffdc848b8 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/image v0.11.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
golang.org/x/term v0.11.0 // indirect
|
||||
golang.org/x/text v0.12.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.12.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.138.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect
|
||||
google.golang.org/grpc v1.57.0 // indirect
|
||||
golang.org/x/image v0.14.0 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/term v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.16.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/api v0.153.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/libc v1.37.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.1 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user