Compare commits

..
99 Commits
Author SHA1 Message Date
Gani Georgiev 6ffc1b2078 added scrollbar gutter for the autoexpand fields to avoid content misalignments 2026-10-02 11:39:39 +03:00
Gani Georgiev 41cac2d0ce updated modernc.org/sqlite to 1.60.0 2026-09-28 22:09:28 +03:00
Gani Georgiev d71a589733 renamed code editor content css class to avoid collisions 2026-09-28 22:07:12 +03:00
Gani Georgiev 35ddf50cbf support Promise as autocomplete suggestions return 2026-09-28 21:56:35 +03:00
Gani Georgiev 5cdbca2b04 more strict cors quick subdomain checks to prevent misuse 2026-09-26 10:25:19 +03:00
Gani Georgiev 768087993e use correct changelog link 2026-09-26 08:05:05 +03:00
Gani Georgiev 211b9645ee clarified cors wildcard support 2026-09-26 08:03:15 +03:00
Gani Georgiev 2ae117228e replaced strings.ToLower with strings.EqualFold 2026-09-26 08:02:13 +03:00
Gani Georgiev 50fe51cecb removed extra semicolon 2026-09-21 12:46:14 +03:00
Gani Georgiev a12bccaafc normalize negative pool size 2026-09-18 01:29:34 +03:00
Gani Georgiev caee2a55f4 fixed typo and updated changelog 2026-09-18 00:54:59 +03:00
Gani Georgiev a47551804c updated modernc.org/sqlite to 1.59.0 2026-09-16 14:42:48 +03:00
Gani Georgiev 86ce1be8fe [#7781] added panic stack trace to routine.SafeWrap and wrapped JSVM migrate arguments so that we can return a clean error with the failed filename 2026-09-15 17:39:59 +03:00
Gani Georgiev 457e094d41 fixed hook name 2026-09-12 23:07:08 +03:00
Gani Georgiev 5cec579da9 bumped app version 2026-09-12 15:27:58 +03:00
Gani Georgiev 5f496665c7 bumped golang.org/x/* deps 2026-09-12 15:27:51 +03:00
Gani Georgiev adf4eb822f renamed OnClearBootstrap to OnBootstrapClear for consistency with the other hooks 2026-09-07 19:58:29 +03:00
Gani Georgiev 114c01ac12 [#7836] fixed migration logs write deadlock and added app.ClearBootstrap/OnClearBootstrap helpers 2026-09-07 19:31:34 +03:00
Gani Georgiev 5684ee24f1 updated changelog 2026-09-06 20:14:05 +03:00
Gani Georgiev b3de909790 [#7834] changed definition to interface 2026-09-06 20:11:04 +03:00
Gani Georgiev a53f189f74 bumped app version 2026-09-06 19:54:38 +03:00
Gani Georgiev 7ff7e0f043 handled io.ReadAll error with exact payload size 2026-09-06 19:51:06 +03:00
Gani Georgiev 6e29284e6a updated changelogs 2026-09-06 17:22:45 +03:00
Gani Georgiev 1984952e4e fixed multiline index column and utrigger index validations if failing to parse all columns 2026-09-06 17:18:49 +03:00
Gani Georgiev f611256d90 removed manual unescape as jsonv2 does it alreadyfor us 2026-09-06 16:29:52 +03:00
Gani Georgiev 10f4f28312 updated test 2026-09-06 15:37:16 +03:00
Gani Georgiev 44f817e7f1 fixed nested self-referenced cascade delete 2026-09-06 15:35:16 +03:00
Gani Georgiev 75c6a4fd1f updated json field validator and allow duplicated keys when serializing record models 2026-09-06 12:31:52 +03:00
Gani Georgiev 57c0b034e6 fixed non-ascii dash in hinted proxy header 2026-09-06 11:22:01 +03:00
Gani Georgiev 4709f63183 optional assert in case the provider doesn't return id_token as string 2026-09-06 11:18:03 +03:00
Gani Georgiev 53a6cd04e2 refactored body limit middleware to prevent unnecessery reads if already beyound the limit 2026-09-06 10:47:59 +03:00
Gani Georgiev df4e6eeb35 clamped arccosine to [-1,1] to prevent rounding errors in geoDistance 2026-09-06 00:45:12 +03:00
Gani Georgiev dbea21f1b2 fixed index validator to allow expressions in the optional where clause 2026-09-05 22:31:16 +03:00
Gani Georgiev 97f9d63a1b commit json status write after checking the fields picker 2026-09-05 18:32:30 +03:00
Gani Georgiev bf12ae0244 updated dark primary btn colors 2026-09-04 00:12:32 +03:00
Gani Georgiev 7ac159b61b bumped golang.org/x/* deps and updated dark mode primary btn contrast 2026-09-03 23:41:10 +03:00
Gani Georgiev 8213ccffb4 renamed variable to avoid confusion 2026-09-02 15:59:58 +03:00
Gani Georgiev e16a9f8b21 bumped goja and regenerated jsvm types 2026-09-02 15:44:19 +03:00
Gani Georgiev 0206f6135c bumped app version 2026-09-02 15:39:09 +03:00
Gani Georgiev e417a26a05 updated backport changelog 2026-09-02 12:41:04 +03:00
Gani Georgiev ac4c30ccbc updated json error test 2026-09-02 12:40:29 +03:00
Gani Georgiev fed5ab9a13 fixed linter warnings 2026-09-02 12:25:01 +03:00
Gani Georgiev 6d9ca9bf9d bumped min go github version to 1.27.1 2026-09-02 12:18:12 +03:00
Gani Georgiev 56f1d1dfdd return an error on invalid fallback param serialization 2026-09-02 12:15:34 +03:00
Gani Georgiev 1b3edbbf5c added invalid index name test 2026-09-02 10:57:00 +03:00
Gani Georgiev 522f9e0ab0 fixed index parsing error when no index name is provided 2026-09-02 10:48:24 +03:00
Gani Georgiev 3c9427667d autocomplete by the word up to the the caret position 2026-09-02 09:37:27 +03:00
Gani Georgiev fd5b9e5f75 clear autocomplete dropdown if empty 2026-09-02 08:10:49 +03:00
Gani Georgiev d0b441bc24 don't show autocomplete if the only suggestion is exact match 2026-09-02 08:05:30 +03:00
Gani Georgiev 229240b374 updated changelog and bumped dev app version 2026-09-02 06:45:06 +03:00
Gani Georgiev f694893d31 bumped highlight limit and slightly debounce autocomplete suggestions 2026-09-02 06:43:11 +03:00
Gani Georgiev 8cb486f24a optimized filter params replacement to use strings.Replacer for a single pass 2026-08-28 12:28:16 +03:00
Gani Georgiev bc8ffed4e7 [#7815] added checks for missing providers prop 2026-08-24 18:00:11 +03:00
Gani Georgiev da18302785 bumped app version 2026-08-24 17:27:07 +03:00
Gani Georgiev b402f11a79 enabled invalid utf8 character replacement by default for the custom json types 2026-08-24 17:26:36 +03:00
Gani Georgiev 9f4f4d4e73 fixed typos 2026-08-24 17:26:01 +03:00
Gani Georgiev d763d3dff1 [#7815] fixed oauth2 providers config merge 2026-08-24 17:07:51 +03:00
Gani Georgiev 04ed202c78 removed unnecessery empty array normalizations because encoding/json/v2 does it by default 2026-08-24 16:46:59 +03:00
Gani Georgiev 756b326bfa allow invalid utf8 characters when marshalizing json responses 2026-08-24 14:13:53 +03:00
Gani Georgiev 50f5f83ace added closed checks as an extra precaution 2026-08-23 00:19:33 +03:00
Gani Georgiev 70d585759d wrap zip close with mutex 2026-08-23 00:05:47 +03:00
Gani Georgiev 9e15b464ba fixed err variable name 2026-08-22 23:43:23 +03:00
Gani Georgiev 129d46f4c8 fixed changelog typo 2026-08-22 23:21:25 +03:00
Gani Georgiev eae29c83a8 updated linter and bumped app version 2026-08-22 23:11:51 +03:00
Gani Georgiev c532fd19b6 wrapped backup zip copy operations in a mutex since the file hook can be triggered by different goroutine 2026-08-22 20:48:54 +03:00
Gani Georgiev eade68d4a3 check for isdir error before logging 2026-08-22 19:27:03 +03:00
Gani Georgiev 18cb3dc62f ugnore dirs to avoid unnecessery logs 2026-08-22 19:18:20 +03:00
Gani Georgiev a293e1301d use the concurrent db pool for the backup db copy 2026-08-22 17:25:37 +03:00
Gani Georgiev 7f4b532f06 updated backport changelog 2026-08-22 16:38:22 +03:00
Gani Georgiev 5e5ba383e8 comment fieldsWithExcerpt to make use of the faster streaming json path 2026-08-22 16:32:11 +03:00
Gani Georgiev b648b951b0 fix some of the flaky tests 2026-08-22 11:33:18 +03:00
Gani Georgiev f544fa0c81 updated tests 2026-08-22 10:10:16 +03:00
Gani Georgiev 80608e8687 updated jstypes 2026-08-21 23:03:19 +03:00
Gani Georgiev 654af12d17 unexport the filesystem hooks for now to avoid introducing breaking changes 2026-08-21 22:58:14 +03:00
Gani Georgiev 0e9dcfe1ab added experimental backup optimizations 2026-08-21 18:50:16 +03:00
Gani Georgiev 25dc8a8eea added Store.Keys() 2026-08-21 14:45:48 +03:00
Gani Georgiev bf1f164014 added Record.GetInt64(field) helper 2026-08-19 21:43:04 +03:00
Gani Georgiev 50a7700450 bumped go github action version to 1.27, updated modernc.org/sqlite and enabled _defensive=1 2026-08-19 21:25:29 +03:00
Gani Georgiev 252358e036 use app.pb.logs.truncate() 2026-08-19 18:12:35 +03:00
Gani Georgiev c2cbd9707f use fixed message limit seperate from the log data 2026-08-19 10:51:06 +03:00
Gani Georgiev e71be7194a added logs truncate endpoint 2026-08-19 10:04:35 +03:00
Gani Georgiev 1a5f29818c split changelogs 2026-08-18 22:56:09 +03:00
Gani Georgiev 25081f14eb make raw json scanning determinisctic and added setting option to limit log data size 2026-08-18 22:46:51 +03:00
Gani Georgiev f5695a7308 bumped app version 2026-08-18 18:34:09 +03:00
Gani Georgiev bad7cc00a1 updated go version in go.mod 2026-08-18 18:24:54 +03:00
Gani Georgiev 14fc20a851 bumped go github action to 1.27rc3 2026-08-18 18:21:29 +03:00
Gani Georgiev 97dd775455 (experimental) upgraded to encoding/json/v2 2026-08-18 18:16:22 +03:00
Gani Georgiev 4d4275c9aa updated custom select class position 2026-08-16 20:45:23 +03:00
Gani Georgiev 1247b24236 synced with master 2026-08-14 10:12:06 +03:00
Gani Georgiev c86fa8890d Merge branch 'master' into develop 2026-08-12 13:02:33 +03:00
Gani Georgiev a4f1fa33d1 added Cross-Origin-Opener-Policy:same-origin to the default security headers 2026-08-11 18:42:26 +03:00
Gani Georgiev 2262bb4cae merged with master 2026-08-10 08:59:16 +03:00
Gani Georgiev 9f1a4bcfa5 minor ui base component fixes and shablon update 2026-08-10 08:15:14 +03:00
Gani Georgiev 32fa2ddf77 added filesystem.NewWriter helper 2026-08-03 20:04:54 +03:00
Gani Georgiev dc7482ea67 [#7785] fixed realtime API preview examples 2026-08-03 16:05:58 +03:00
Gani Georgiev b259534df7 added quotes for the default Content-Disposition serving filename in case custom name with special characters is provided 2026-08-02 20:18:02 +03:00
Gani Georgiev 4c6c166603 order new files at the end 2026-08-02 15:45:31 +03:00
Gani Georgiev f9795da8c7 added hasArchiveExtension util 2026-08-02 14:20:31 +03:00
Gani Georgiev 200c19b26f added non-zero exit status support for returned command errors 2026-08-01 15:05:40 +03:00
204 changed files with 9030 additions and 6151 deletions

No files matched your search

+1 -1
View File
@@ -59,7 +59,7 @@ For the cases where transactions are really needed, users can utilize the [Batch
<details> <details>
<summary><strong>List/Search side-channel attacks</strong></summary> <summary><strong>List/Search side-channel attacks</strong></summary>
Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_. Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG_23_39.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
This is by design and it is accepted tradeoff between performance, security and usability. This is by design and it is accepted tradeoff between performance, security and usability.
+2 -2
View File
@@ -26,9 +26,9 @@ jobs:
node-version: '>=25.2.1' node-version: '>=25.2.1'
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: '>=1.26.6' go-version: '>=1.27.1'
# This step usually is not needed because the /ui/dist is pregenerated locally # 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. # but its here to ensure that each release embeds the latest admin ui artifacts.
+56 -1196
View File
File diff suppressed because it is too large. Load diff
File renamed without changes.
+23
View File
@@ -2,6 +2,29 @@
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md) > For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
--- ---
## v0.22.55
- (_Backported from v0.40.3_) Fixed collection index validator to allow expressions with parenthesis in the optional `WHERE` clause.
- (_Backported from v0.40.3_) Fixed nested cascade delete of self-referenced relation records.
- (_Backported from v0.40.3_) Bumped `golang.org/x/*` dependencies to silence security scanners ([#7829](https://github.com/pocketbase/pocketbase/discussions/7829)).
## v0.22.54
- (_Backported from v0.40.2_) Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
- (_Backported from v0.40.2_) Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
- (_Backported from v0.40.2_) Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
## v0.22.53
- Workaround `encoding/json` infinite recursion when used with Go 1.27 ([#7808](https://github.com/pocketbase/pocketbase/issues/7808)).
## v0.22.52 ## v0.22.52
- (_Backported from v0.39.11_) Bumped `golang.org/x/*` deps and the min Go GitHub action version to 1.26.6 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.6). - (_Backported from v0.39.11_) Bumped `golang.org/x/*` deps and the min Go GitHub action version to 1.26.6 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.6).
+1243
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,11 +9,11 @@ This document describes how to prepare a PR for a change in the main repository.
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [Making changes in the Go code](#making-changes-in-the-go-code) - [Making changes in the Go code](#making-changes-in-the-go-code)
- [Making changes in the Superuser UI](#making-changes-in-the-admin-ui) - [Making changes in the Superuser UI](#making-changes-in-the-superuser-ui)
## Prerequisites ## Prerequisites
- Go 1.25+ (for making changes in the Go code) - Go 1.27+ (for making changes in the Go code)
- Node 24+ (for making changes in the Superuser UI) - Node 24+ (for making changes in the Superuser UI)
If you haven't already, you can fork the main repository and clone your fork so that you can work locally: If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
+27 -22
View File
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
Here is a minimal example: Here is a minimal example:
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_) 0. [Install Go 1.27+](https://go.dev/doc/install) (_if you haven't already_)
1. Create a new project directory with the following `main.go` file inside it: 1. Create a new project directory with the following `main.go` file inside it:
```go ```go
@@ -92,32 +92,37 @@ _For more details please refer to [Extend with Go](https://pocketbase.io/docs/go
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory: To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_) 0. [Install Go 1.27+](https://go.dev/doc/install) (_if you haven't already_)
1. Clone/download the repo 1. Clone/download the repo
2. Navigate to `examples/base` 2. Navigate to `examples/base`
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build` 3. Run `CGO_ENABLED=0 go build` to build a binary for your current environment
(_https://go.dev/doc/install/source#environment_) _(or to target other platforms use `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`; see https://go.dev/doc/install/source#environment)_
4. Start the created executable by running `./base serve`. 4. Start the created executable by running `./base serve`.
Note that the supported build targets by the pure Go SQLite driver at the moment are: Note that the supported build targets by the pure Go SQLite driver at the moment are:
``` | GOOS | GOARCH |
darwin amd64 |---------|---------|
darwin arm64 | darwin | amd64 |
freebsd amd64 | darwin | arm64 |
freebsd arm64 | freebsd | 386 |
linux 386 | freebsd | amd64 |
linux amd64 | freebsd | arm |
linux arm | freebsd | arm64 |
linux arm64 | linux | 386 |
linux loong64 | linux | amd64 |
linux ppc64le | linux | arm |
linux riscv64 | linux | arm64 |
linux s390x | linux | loong64 |
windows 386 | linux | ppc64le |
windows amd64 | linux | riscv64 |
windows arm64 | linux | s390x |
``` | netbsd | amd64 |
| openbsd | amd64 |
| openbsd | arm64 |
| windows | 386 |
| windows | amd64 |
| windows | arm64 |
### Testing ### Testing
@@ -134,7 +139,7 @@ Check also the [Testing guide](http://pocketbase.io/docs/testing) to learn how t
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**. If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
All reports will be promptly addressed and you'll be credited in the fix release notes. You could find more details in the project [Security policy](https://github.com/pocketbase/pocketbase/security/policy).
## Contributing ## Contributing
+1 -1
View File
@@ -2,7 +2,7 @@ package apis
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"io" "io"
"mime/multipart" "mime/multipart"
+66 -1
View File
@@ -1225,6 +1225,71 @@ func TestCollectionUpdate(t *testing.T) {
"OnModelValidate": 1, "OnModelValidate": 1,
}, },
}, },
{
Name: "add another OAuth2 provider to an auth collection",
Method: http.MethodPatch,
URL: "/api/collections/users",
Body: strings.NewReader(`{
"oauth2": {
"providers": [
{"name": "apple", "clientId": "a", "clientSecret": "b"},
{
"pkce": null,
"name": "google",
"authURL": "",
"displayName": "existing",
"extra": {}
}
]
}
}`),
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
// verify that the collection has google and gitlab as OAuth2 providers
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
t.Fatal(err)
}
if v := len(users.OAuth2.Providers); v != 2 {
t.Fatalf("Expected 2 OAuth2 providers, got %d", v)
}
if v := users.OAuth2.Providers[0].Name; v != "gitlab" {
t.Fatalf("Expected provider 0 to be %s, got %s", "gitlab", v)
}
if v := users.OAuth2.Providers[1].Name; v != "google" {
t.Fatalf("Expected provider 1 to be %s, got %s", "google", v)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"google"`,
`"name":"apple"`,
`"displayName":"existing"`,
`"clientId":"test"`,
`"clientId":"a"`,
},
NotExpectedContent: []string{
`"name":"gitlab"`,
`clientSecret`,
},
ExpectedEvents: map[string]int{
"*": 0,
"OnCollectionUpdateRequest": 1,
"OnCollectionUpdate": 1,
"OnCollectionUpdateExecute": 1,
"OnCollectionAfterUpdateSuccess": 1,
"OnCollectionValidate": 1,
"OnModelUpdate": 1,
"OnModelUpdateExecute": 1,
"OnModelAfterUpdateSuccess": 1,
"OnModelValidate": 1,
},
},
// view // view
// ----------------------------------------------------------- // -----------------------------------------------------------
@@ -1617,7 +1682,7 @@ func TestCollectionOAuth2Providers(t *testing.T) {
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{ ExpectedContent: []string{
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`, `{"name":"oidc3","displayName":"OIDC","logo":"<svg`,
}, },
NotExpectedContent: []string{ NotExpectedContent: []string{
`"order":`, `"order":`,
+3 -4
View File
@@ -77,10 +77,13 @@ func TestCronsRun(t *testing.T) {
app.Cron().Add("test", "* * * * *", func() { app.Cron().Add("test", "* * * * *", func() {
app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1) app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1)
}) })
app.Cron().Stop()
} }
expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) { expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) {
return func(t testing.TB, app *tests.TestApp, res *http.Response) { return func(t testing.TB, app *tests.TestApp, res *http.Response) {
time.Sleep(50 * time.Millisecond)
total := cast.ToInt(app.Store().Get("testJobCalls")) total := cast.ToInt(app.Store().Get("testJobCalls"))
if total != expected { if total != expected {
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total) t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
@@ -93,7 +96,6 @@ func TestCronsRun(t *testing.T) {
Name: "unauthorized", Name: "unauthorized",
Method: http.MethodPost, Method: http.MethodPost,
URL: "/api/crons/test", URL: "/api/crons/test",
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0), AfterTestFunc: expectedCalls(0),
ExpectedStatus: 401, ExpectedStatus: 401,
@@ -107,7 +109,6 @@ func TestCronsRun(t *testing.T) {
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
}, },
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0), AfterTestFunc: expectedCalls(0),
ExpectedStatus: 403, ExpectedStatus: 403,
@@ -121,7 +122,6 @@ func TestCronsRun(t *testing.T) {
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
}, },
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(0), AfterTestFunc: expectedCalls(0),
ExpectedStatus: 404, ExpectedStatus: 404,
@@ -135,7 +135,6 @@ func TestCronsRun(t *testing.T) {
Headers: map[string]string{ Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY", "Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
}, },
Delay: 50 * time.Millisecond,
BeforeTestFunc: beforeTestFunc, BeforeTestFunc: beforeTestFunc,
AfterTestFunc: expectedCalls(1), AfterTestFunc: expectedCalls(1),
ExpectedStatus: 204, ExpectedStatus: 204,
+1 -1
View File
@@ -37,7 +37,7 @@ func healthCheck(e *core.RequestEvent) error {
headersToCheck := append( headersToCheck := append(
slices.Clone(e.App.Settings().TrustedProxy.Headers), slices.Clone(e.App.Settings().TrustedProxy.Headers),
// common proxy headers // common proxy headers
"CF-Connecting-IP", "Fly-Client-IP", "X‑Forwarded-For", "CF-Connecting-IP", "Fly-Client-IP", "X-Forwarded-For",
) )
for _, header := range headersToCheck { for _, header := range headersToCheck {
if e.Request.Header.Get(header) != "" { if e.Request.Header.Get(header) != "" {
+22 -1
View File
@@ -11,8 +11,13 @@ import (
// bindLogsApi registers the request logs api endpoints. // bindLogsApi registers the request logs api endpoints.
func bindLogsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) { func bindLogsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
sub := rg.Group("/logs").Bind(RequireSuperuserAuth(), SkipSuccessActivityLog()) sub := rg.Group("/logs").Bind(
RequireSuperuserAuth(),
SkipSuccessActivityLog(),
)
sub.GET("", logsList) sub.GET("", logsList)
sub.DELETE("", logsTruncate)
sub.GET("/stats", logsStats) sub.GET("/stats", logsStats)
sub.GET("/{id}", logsView) sub.GET("/{id}", logsView)
} }
@@ -71,3 +76,19 @@ func logsView(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, log) return e.JSON(http.StatusOK, log)
} }
func logsTruncate(e *core.RequestEvent) error {
// delete all rows directly (aka. no model hooks will be fired)
_, err := e.App.AuxNonconcurrentDB().Delete((&core.Log{}).TableName(), nil).Execute()
if err != nil {
return e.InternalServerError("Failed to truncate all logs.", err)
}
// try to free the unused disk space
err = e.App.AuxVacuum()
if err != nil {
e.App.Logger().Warn("Failed to VACUUM aux database", "error", err)
}
return e.NoContent(http.StatusNoContent)
}
+56
View File
@@ -210,3 +210,59 @@ func TestLogsStats(t *testing.T) {
scenario.Test(t) scenario.Test(t)
} }
} }
func TestLogsTruncate(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
URL: "/api/logs",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as regular user",
Method: http.MethodDelete,
URL: "/api/logs",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "authorized as superuser",
Method: http.MethodDelete,
URL: "/api/logs",
Headers: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
if err := tests.StubLogsData(app); err != nil {
t.Fatal(err)
}
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
var found []core.Log
if err := app.LogQuery().All(&found); err != nil {
t.Fatal(err)
}
if len(found) > 0 {
t.Fatalf("Expected all logs to be deleted, found: %v", found)
}
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+1
View File
@@ -293,6 +293,7 @@ func securityHeaders() *hook.Handler[*core.RequestEvent] {
e.Response.Header().Set("X-XSS-Protection", "1; mode=block") e.Response.Header().Set("X-XSS-Protection", "1; mode=block")
e.Response.Header().Set("X-Content-Type-Options", "nosniff") e.Response.Header().Set("X-Content-Type-Options", "nosniff")
e.Response.Header().Set("X-Frame-Options", "SAMEORIGIN") e.Response.Header().Set("X-Frame-Options", "SAMEORIGIN")
e.Response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
// @todo consider a default HSTS? // @todo consider a default HSTS?
// (see also https://webkit.org/blog/8146/protecting-against-hsts-abuse/) // (see also https://webkit.org/blog/8146/protecting-against-hsts-abuse/)
+46 -22
View File
@@ -84,49 +84,73 @@ func applyBodyLimit(e *core.RequestEvent, limitBytes int64) error {
} }
// replace the request body // replace the request body
// e.Request.Body = newMaxBytesReader(e.Request.Body, limitBytes)
// note: we don't use sync.Pool since the size of the elements could vary too much
// and it might not be efficient (see https://github.com/golang/go/issues/23199)
e.Request.Body = &limitedReader{ReadCloser: e.Request.Body, limit: limitBytes}
return nil return nil
} }
type limitedReader struct { func newMaxBytesReader(body io.ReadCloser, limitBytes int64) *maxBytesReader {
io.ReadCloser return &maxBytesReader{
limit int64 ReadCloser: body,
totalRead int64 limit: limitBytes,
remaining: limitBytes,
}
} }
func (r *limitedReader) Read(b []byte) (int, error) { // maxBytesReader is very similar to the http.MaxBytesReader but support
// rereads and doesn't try to prematurely close the related response
// to allow consequent middlewares to operate correctly.
type maxBytesReader struct {
io.ReadCloser
limit int64
remaining int64
stickyErr error
}
func (r *maxBytesReader) Read(b []byte) (int, error) {
if r.stickyErr != nil {
return 0, r.stickyErr
}
if len(b) == 0 {
return 0, nil
}
// if possible no need to read the entire chunk since
// remaining+1 is enough to determine whether it exceed the limit
if int64(len(b))-1 > r.remaining {
b = b[:r.remaining+1]
}
n, err := r.ReadCloser.Read(b) n, err := r.ReadCloser.Read(b)
if err != nil {
if int64(n) <= r.remaining {
r.remaining -= int64(n)
r.stickyErr = err
return n, err return n, err
} }
r.totalRead += int64(n) n = int(r.remaining)
if r.totalRead > r.limit {
return n, ErrRequestEntityTooLarge
}
return n, nil r.remaining = 0
r.stickyErr = ErrRequestEntityTooLarge
return n, r.stickyErr
} }
// explicit casts to ensure that the main struct methods will be invoked // explicit casts to ensure that the main struct methods will be invoked
// (extra precautions in case of nested interface wrapping erasure) // (extra precautions in case of nested interface wrapping erasure)
// --- // ---
func (r *limitedReader) Reread() { func (r *maxBytesReader) Reread() {
rereader, ok := r.ReadCloser.(router.Rereader) rereader, ok := r.ReadCloser.(router.Rereader)
if ok { if ok {
rereader.Reread() rereader.Reread()
r.remaining = r.limit
r.stickyErr = nil
} }
} }
func (r *limitedReader) Close() error { func (r *maxBytesReader) Close() error {
closer, ok := r.ReadCloser.(io.Closer) return r.ReadCloser.Close()
if ok {
return closer.Close()
}
return nil
} }
+108 -15
View File
@@ -1,9 +1,10 @@
package apis_test package apis_test
import ( import (
"bytes" "io"
"fmt" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -19,13 +20,35 @@ func TestBodyLimitMiddleware(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pbRouter.POST("/a", func(e *core.RequestEvent) error {
return e.String(200, "a")
}) // default global BodyLimit check
pbRouter.POST("/b", func(e *core.RequestEvent) error { testHandler := func(e *core.RequestEvent) error {
return e.String(200, "b") // read the body multiple times to ensure that the limited
}).Bind(apis.BodyLimit(20)) // reader guards and rereads are invoked
var result any
if err := e.BindBody(&result); err != nil {
return err
}
if err := e.BindBody(&result); err != nil {
return err
}
return e.JSON(200, result)
}
const customLimit = 20
pbRouter.POST("/a", testHandler) // default global BodyLimit check
pbRouter.POST("/b", testHandler).Bind(apis.BodyLimit(customLimit))
pbRouter.POST("/iof", func(e *core.RequestEvent) error {
// ensure that normal io methods still operate correctly
b, err := io.ReadAll(e.Request.Body)
if err != nil {
return err
}
return e.String(http.StatusOK, string(b))
}).Bind(apis.BodyLimit(customLimit))
mux, err := pbRouter.BuildMux() mux, err := pbRouter.BuildMux()
if err != nil { if err != nil {
@@ -33,20 +56,90 @@ func TestBodyLimitMiddleware(t *testing.T) {
} }
scenarios := []struct { scenarios := []struct {
name string
url string url string
size int64 body string
lazyContentLength bool
expectedStatus int expectedStatus int
}{ }{
{"/a", 21, 200}, {
{"/a", apis.DefaultMaxBodySize + 1, 413}, "(eager content-length check) with body = default limit",
{"/b", 20, 200}, "/a",
{"/b", 21, 413}, `"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
false,
http.StatusOK,
},
{
"(eager content-length check) with body > default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
false,
http.StatusRequestEntityTooLarge,
},
{
"(lazy content-length check) with body = default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize-2)) + `"`,
true,
http.StatusOK,
},
{
"(lazy content-length check) with body > default limit",
"/a",
`"` + strings.Repeat("a", int(apis.DefaultMaxBodySize)) + `"`,
true,
http.StatusRequestEntityTooLarge,
},
// ---
{
"(eager content-length check) with body = custom limit",
"/b",
`"` + strings.Repeat("a", customLimit-2) + `"`,
false,
http.StatusOK,
},
{
"(eager content-length check) with body > custom limit",
"/b",
`"` + strings.Repeat("a", customLimit) + `"`,
false,
http.StatusRequestEntityTooLarge,
},
{
"(lazy content-length check) with body = custom limit",
"/b",
`"` + strings.Repeat("a", customLimit-2) + `"`,
true,
http.StatusOK,
},
{
"(lazy content-length check) with body > custom limit",
"/b",
`"` + strings.Repeat("a", customLimit) + `"`,
true,
http.StatusRequestEntityTooLarge,
},
// ---
{
"io.ReadAll io.EOF exact limit check",
"/iof",
`"` + strings.Repeat("a", customLimit-2) + `"`,
true,
http.StatusOK,
},
} }
for _, s := range scenarios { for _, s := range scenarios {
t.Run(fmt.Sprintf("%s_%d", s.url, s.size), func(t *testing.T) { t.Run(s.name, func(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", s.url, bytes.NewReader(make([]byte, s.size)))
req := httptest.NewRequest("POST", s.url, strings.NewReader(s.body))
req.Header.Set("Content-Type", "application/json")
if s.lazyContentLength {
req.ContentLength = -1
}
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
result := rec.Result() result := rec.Result()
+14 -9
View File
@@ -32,8 +32,10 @@ const (
type CORSConfig struct { type CORSConfig struct {
// AllowOrigins determines the value of the Access-Control-Allow-Origin // AllowOrigins determines the value of the Access-Control-Allow-Origin
// response header. This header defines a list of origins that may access the // response header. This header defines a list of origins that may access the
// resource. The wildcard characters '*' and '?' are supported and are // resource.
// converted to regex fragments '.*' and '.' accordingly. //
// The wildcard characters '*' and '?' are supported as subdomain segments
// and are converted to regex fragments '.*' and '.' accordingly.
// //
// Security: use extreme caution when handling the origin, and carefully // Security: use extreme caution when handling the origin, and carefully
// validate any logic. Remember that attackers may register hostile domain names. // validate any logic. Remember that attackers may register hostile domain names.
@@ -209,7 +211,7 @@ func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
allowOrigin = o allowOrigin = o
break break
} }
if matchSubdomain(origin, o) { if exactWildcardMatch(origin, o) {
allowOrigin = origin allowOrigin = origin
break break
} }
@@ -281,8 +283,8 @@ func matchScheme(domain, pattern string) bool {
return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx] return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx]
} }
// matchSubdomain compares authority with wildcard // exactWildcardMatch compares domain with a * wildcard pattern
func matchSubdomain(domain, pattern string) bool { func exactWildcardMatch(domain, pattern string) bool {
if !matchScheme(domain, pattern) { if !matchScheme(domain, pattern) {
return false return false
} }
@@ -310,18 +312,21 @@ func matchSubdomain(domain, pattern string) bool {
patComp[i], patComp[opp] = patComp[opp], patComp[i] patComp[i], patComp[opp] = patComp[opp], patComp[i]
} }
for i, v := range domComp { if len(patComp) != len(domComp) {
if len(patComp) <= i {
return false return false
} }
for i, v := range domComp {
p := patComp[i] p := patComp[i]
if p == "*" { if p == "*" {
return true continue
} }
if p != v { if p != v {
return false return false
} }
} }
return false return true
} }
+39
View File
@@ -0,0 +1,39 @@
package apis
import "testing"
func TestCorsExactWildcardMatch(t *testing.T) {
t.Parallel()
scenarios := []struct {
domain string
pattern string
expected bool
}{
{"", "", false},
{"http://example.com", "", false},
{"", "http://example.com", false},
{"http://example.com", "https://example.com", false},
{"abc://example.com", "abc://example.com", true},
{"https://example.com", "https://example.com", true},
{"https://a.example.com", "https://example.com", false},
{"https://example.com", "https://a.example.com", false},
{"https://example.com", "https://*.example.com", false},
{"https://a.example.com", "https://*.example.com", true},
{"https://a.example.com", "https://a.*.example.com", false},
{"https://a.b.example.com", "https://a.*.example.com", true},
{"https://a.b.example.com", "https://a2.*.example.com", false},
{"https://a.b.example.com", "https://a.*", false},
{"https://a.b.example", "https://a.*", false},
{"https://a.b", "https://a.*", true},
}
for _, s := range scenarios {
t.Run(s.domain+":"+s.pattern, func(t *testing.T) {
result := exactWildcardMatch(s.domain, s.pattern)
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
+11 -7
View File
@@ -1,8 +1,10 @@
package apis_test package apis_test
import ( import (
"fmt"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"testing/synctest"
"time" "time"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -127,27 +129,29 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
{"/rate/guest", 0, true, 429}, {"/rate/guest", 0, true, 429},
} }
for _, s := range scenarios { synctest.Test(t, func(t *testing.T) {
t.Run(s.url, func(t *testing.T) { for i, s := range scenarios {
prefix := fmt.Sprintf("[%s:%d] ", s.url, i+1)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", s.url, nil) req := httptest.NewRequest("GET", s.url, nil)
if s.authenticated { if s.authenticated {
auth, err := app.FindAuthRecordByEmail("users", "test@example.com") auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf(prefix+"%v", err)
} }
token, err := auth.NewAuthToken() token, err := auth.NewAuthToken()
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf(prefix+"%v", err)
} }
req.Header.Add("Authorization", token) req.Header.Add("Authorization", token)
} }
if s.wait > 0 { if s.wait > 0 {
time.Sleep(time.Duration(s.wait) * time.Millisecond) synctest.Sleep(time.Duration(s.wait) * time.Millisecond)
} }
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
@@ -155,10 +159,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
result := rec.Result() result := rec.Result()
if result.StatusCode != s.expectedStatus { if result.StatusCode != s.expectedStatus {
t.Fatalf("Expected response status %d, got %d", s.expectedStatus, result.StatusCode) t.Fatalf(prefix+"Expected response status %d, got %d", s.expectedStatus, result.StatusCode)
}
} }
}) })
}
} }
func TestDefaultRateLimitMiddlewareSkipChecks(t *testing.T) { func TestDefaultRateLimitMiddlewareSkipChecks(t *testing.T) {
+1 -1
View File
@@ -2,7 +2,7 @@ package apis
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
+4 -2
View File
@@ -2,7 +2,7 @@ package apis_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -1171,7 +1171,9 @@ func TestRealtimeRecordResolve(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
notify := func(clientId string, eventData []byte) { notify := func(clientId string, eventData []byte) {
data := struct{ Action string }{} data := struct {
Action string `json:"action"`
}{}
_ = json.Unmarshal(eventData, &data) _ = json.Unmarshal(eventData, &data)
mu.Lock() mu.Lock()
+1 -1
View File
@@ -54,7 +54,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
`"providers":[{`, `"providers":[{`,
`"name":"google"`, `"name":"google"`,
`"name":"gitlab"`, `"name":"gitlab"`,
`"logo":"\u003csvg`, `"logo":"<svg`,
`"logo":""`, // for the legacy fields `"logo":""`, // for the legacy fields
`"state":`, `"state":`,
`"displayName":`, `"displayName":`,
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
+1 -1
View File
@@ -1,7 +1,7 @@
package apis package apis
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
+1 -1
View File
@@ -1,7 +1,7 @@
package apis_test package apis_test
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
+28 -2
View File
@@ -54,11 +54,16 @@ type App interface {
// Bootstrap initializes the application // Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.). // (aka. create data dir, open db connections, load settings, etc.).
// //
// It will call ResetBootstrapState() if the application was already bootstrapped. // It calls ClearBootstrap() if the application was already bootstrapped.
Bootstrap() error Bootstrap() error
// ResetBootstrapState releases the initialized core app resources // ClearBootstrap releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.). // (closing db connections, stopping cron ticker, etc.).
//
// This method is no-op if the application is not bootstrapped yet.
ClearBootstrap() error
// Deprecated: use ClearBootstrap().
ResetBootstrapState() error ResetBootstrapState() error
// DataDir returns the app data directory path. // DataDir returns the app data directory path.
@@ -713,6 +718,13 @@ type App interface {
// resources (db, app settings, etc). // resources (db, app settings, etc).
OnBootstrap() *hook.Hook[*BootstrapEvent] OnBootstrap() *hook.Hook[*BootstrapEvent]
// OnBootstrapClear hook is triggered when clearing the main application
// resources (db connections, cron, logger, etc.)
//
// It is usually invoked automatically right before app termination
// or when manually calling app.ClearBootstrap().
OnBootstrapClear() *hook.Hook[*BootstrapEvent]
// OnServe hook is triggered when the app web server is started // OnServe hook is triggered when the app web server is started
// (after starting the TCP listener but before initializing the blocking serve task), // (after starting the TCP listener but before initializing the blocking serve task),
// allowing you to adjust its options and attach new routes or middlewares. // allowing you to adjust its options and attach new routes or middlewares.
@@ -1262,6 +1274,20 @@ type App interface {
// triggered and called only if their event data origin matches the tags. // triggered and called only if their event data origin matches the tags.
OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent] OnMailerRecordOTPSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
// ---------------------------------------------------------------
// Filesystem event hooks
// (not publicly exposed until file_field refactoring)
// ---------------------------------------------------------------
// onFilesystemNewWriter is an internal hook for app.NewFilesystem()
// instances that is triggered on every storage filesystem writer initialization
// (aka. whenever attempting to create a new file).
onFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent]
// onFilesystemDelete is an internal hook for app.NewFilesystem()
// instances that is triggered for every storage file delete call.
onFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent]
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Realtime API event hooks // Realtime API event hooks
// --------------------------------------------------------------- // ---------------------------------------------------------------
+137
View File
@@ -0,0 +1,137 @@
package core
import (
"context"
"fmt"
"log/slog"
"sort"
"time"
"github.com/pocketbase/pocketbase/tools/inflector"
)
const (
StoreKeyActiveBackup = "@activeBackup"
)
// generateBackupName generates a new backup name based on the app name and current date.
func generateBackupName(app App, prefix string) string {
appName := inflector.Snakecase(app.Settings().Meta.AppName)
if len(appName) > 50 {
appName = appName[:50]
}
return fmt.Sprintf(
"%s%s_%s.zip",
prefix,
appName,
time.Now().UTC().Format("20060102150405"),
)
}
// registerAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) registerAutobackupHooks() {
const jobId = "__pbAutoBackup__"
loadJob := func() {
rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" {
app.Cron().Remove(jobId)
return
}
app.Cron().Add(jobId, rawSchedule, func() {
const autoPrefix = "@auto_pb_backup_"
name := generateBackupName(app, autoPrefix)
if err := app.CreateBackup(context.Background(), name); err != nil {
app.Logger().Error(
"[Backup cron] Failed to create backup",
slog.String("name", name),
slog.String("error", err.Error()),
)
alertError := sendSystemAlertToAllSuperusers(
app,
"Autobackup failure",
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
)
if alertError != nil {
app.Logger().Warn(
"[Backup cron] Failed to send backup error alerts",
slog.String("name", name),
slog.String("error", alertError.Error()),
)
}
}
maxKeep := app.Settings().Backups.CronMaxKeep
if maxKeep == 0 {
return // no explicit limit
}
fsys, err := app.NewBackupsFilesystem()
if err != nil {
app.Logger().Error(
"[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.Logger().Error(
"[Backup cron] Failed to list autogenerated backups",
slog.String("error", err.Error()),
)
return
}
if maxKeep >= len(files) {
return // nothing to remove
}
// sort desc
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime.After(files[j].ModTime)
})
// keep only the most recent n auto backup files
toRemove := files[maxKeep:]
for _, f := range toRemove {
if err := fsys.Delete(f.Key); err != nil {
app.Logger().Error(
"[Backup cron] Failed to remove old autogenerated backup",
slog.String("key", f.Key),
slog.String("error", err.Error()),
)
}
}
})
}
app.OnBootstrap().BindFunc(func(e *BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
loadJob()
return nil
})
app.OnSettingsReload().BindFunc(func(e *SettingsReloadEvent) error {
if err := e.Next(); err != nil {
return err
}
loadJob()
return nil
})
}
+437
View File
@@ -0,0 +1,437 @@
package core
import (
"archive/zip"
"compress/flate"
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/store"
)
var errIsDir = errors.New("the specified path is a directory and not a regular file")
// CreateBackup creates a new backup of the current app pb_data directory.
//
// If name is empty, it will be autogenerated.
// If backup with the same name exists, the new backup file will replace it.
//
// To safely perform the backup, it is recommended to have free disk space
// for at least 2x the size of the pb_data directory.
//
// By default backups are stored in pb_data/backups
// (the backups directory itself is excluded from the generated backup).
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
// When using S3 storage for the uploaded collection files, you have to
// take care manually to backup those since they are not part of the pb_data.
//
// DB write locks are minimal and isolated only for the duration of the
// VACUUM INTO statement that creates a live copy of the app database.
//
// The backup works as follow:
//
// 1. Start listening for DELETED storage files.
// In case a file is being deleted while the backup is still ongoing,
// we directly copy it in the zip before the deletion and mark it as "excluded".
//
// 2. Copy the main database with VACUUM INTO, write it in the zip and mark it as "excluded".
//
// 3. Stop listening for DELETED files.
//
// 4. Start listening for NEW storage files and mark all new files from this point as "excluded".
//
// 5. Copy the logs database with VACUUM INTO, write it in the zip and mark it as "excluded".
//
// 6. Copy the rest of the pb_data files in the zip while ignoring the "excluded" list (it should be concurrent safe).
//
// 7. Stop listening for NEW storage files.
//
// While there is a risk for a race condition between steps 1, 2 and 3, it is an
// acceptable trade-off between performance and correctness because in
// the worst case there will be some unused storage files in the backup that don't do any harm.
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
if app.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
startTime := time.Now()
event := new(BackupEvent)
event.App = app
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup generation
event.Exclude = []string{
LocalBackupsDirName,
LocalTempDirName,
LocalNotifyDirName,
LocalAutocertCacheDirName,
lostFoundDirName,
}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
if e.Name == "" {
e.Name = generateBackupName(e.App, "pb_backup_")
}
app.Logger().Debug("[" + e.Name + "] zip archive started")
// create backup zip
// (it needs to be inside the current pb_data to avoid "cross-device link" errors)
// -----------------------------------------------------------
tempZipPath := filepath.Join(app.DataDir(), LocalTempDirName, "pb_backup_"+security.PseudorandomString(6))
err := createZip(e, tempZipPath)
if err != nil {
return err
}
defer os.Remove(tempZipPath)
// persist the backup in the backups filesystem
// -----------------------------------------------------------
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(e.Context)
file, err := filesystem.NewFileFromPath(tempZipPath)
if err != nil {
return err
}
file.OriginalName = e.Name
file.Name = file.OriginalName
err = fsys.UploadFile(file, file.Name)
if err != nil {
return err
}
app.Logger().Debug(
"["+e.Name+"] zip archive completed",
slog.Float64("execTime", float64(time.Since(startTime))/float64(time.Millisecond)),
)
return nil
})
}
func createZip(be *BackupEvent, tempZipPath string) error {
logPrefix := "[" + be.Name + "] "
// make sure that the special temp directory exists
localTempDir := filepath.Dir(tempZipPath)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf(logPrefix+"failed to create temp dir: %w", err)
}
const tempFilesHookId = "__pbTempBackupFilesystemWatcher__"
defer func() {
// unbind again in cacase of an error
be.App.onFilesystemDelete().Unbind(tempFilesHookId)
be.App.onFilesystemNewWriter().Unbind(tempFilesHookId)
}()
zipper, err := newZipWriter(tempZipPath)
if err != nil {
return err
}
defer zipper.close()
excluded := store.New[string, struct{}](nil)
for _, name := range be.Exclude {
excluded.Set(normalizePathExclude(name), struct{}{})
}
// init deleted files tracker
// ---------------------------------------------------------------
be.App.onFilesystemDelete().Bind(&hook.Handler[*FilesystemDeleteEvent]{
Id: tempFilesHookId,
Priority: -99,
Func: func(e *FilesystemDeleteEvent) error {
// note: the zip header name allow only forward slashes
zipPath := path.Join(LocalStorageDirName, e.FileKey)
if excluded.Has(normalizePathExclude(zipPath)) || be.App.Settings().S3.Enabled {
return e.Next()
}
localPath := filepath.Join(
be.App.DataDir(),
LocalStorageDirName,
e.FileKey,
)
// copy to zip before delete
err := zipper.copyFileToZip(localPath, zipPath)
if err != nil {
// it is ok to ignore directories
if !errors.Is(err, errIsDir) {
be.App.Logger().Warn(
logPrefix+"failed to copy file in backup zip before delete",
slog.Any("error", err),
slog.String("file", e.FileKey),
)
}
} else {
// mark that it was already copied
excluded.Set(normalizePathExclude(zipPath), struct{}{})
}
// proceed with the normal deletion
return e.Next()
},
})
// copy data.db
// ---------------------------------------------------------------
dataStartTime := time.Now()
tempDataDBPath := filepath.Join(localTempDir, dataDBFilename)
_, err = be.App.ConcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempDataDBPath}).Execute()
if err != nil {
return err
}
// eagerly stop listening for deleted files since we already have what we needed
be.App.onFilesystemDelete().Unbind(tempFilesHookId)
be.App.Logger().Debug(
logPrefix+dataDBFilename+" copy completed",
slog.Float64("execTime", float64(time.Since(dataStartTime))/float64(time.Millisecond)),
)
err = zipper.copyFileToZip(tempDataDBPath, dataDBFilename)
if err != nil {
_ = os.Remove(tempDataDBPath)
return err
}
_ = os.Remove(tempDataDBPath)
excluded.Set(normalizePathExclude(dataDBFilename), struct{}{})
excluded.Set(normalizePathExclude(dataDBFilename+"-wal"), struct{}{})
excluded.Set(normalizePathExclude(dataDBFilename+"-shm"), struct{}{})
// init to-be-created files tracker
// ---------------------------------------------------------------
be.App.onFilesystemNewWriter().Bind(&hook.Handler[*FilesystemNewWriterEvent]{
Id: tempFilesHookId,
Priority: -99,
Func: func(e *FilesystemNewWriterEvent) error {
if !be.App.Settings().S3.Enabled {
// mark for exclude even if the writer eventually fails
// (all record files have random name so collisions are unlikely)
name := normalizePathExclude(filepath.Join(LocalStorageDirName, e.FileKey))
excluded.Set(name, struct{}{})
}
return e.Next()
},
})
// copy auxiliary.db
// ---------------------------------------------------------------
auxStartTime := time.Now()
tempAuxDBPath := filepath.Join(localTempDir, auxDBFilename)
_, err = be.App.AuxConcurrentDB().NewQuery("VACUUM INTO {:path}").Bind(dbx.Params{"path": tempAuxDBPath}).Execute()
if err != nil {
return err
}
be.App.Logger().Debug(
logPrefix+auxDBFilename+" copy completed",
slog.Float64("execTime", float64(time.Since(auxStartTime))/float64(time.Millisecond)),
)
err = zipper.copyFileToZip(tempAuxDBPath, auxDBFilename)
if err != nil {
_ = os.Remove(tempAuxDBPath)
return err
}
_ = os.Remove(tempAuxDBPath)
excluded.Set(normalizePathExclude(auxDBFilename), struct{}{})
excluded.Set(normalizePathExclude(auxDBFilename+"-wal"), struct{}{})
excluded.Set(normalizePathExclude(auxDBFilename+"-shm"), struct{}{})
// try to run manual checkpoints to ensure that all wal writes during the
// previous VACUUM INTO are transferred and don't accumulate
// (errors are ignore because some drivers may not support the wal_checkpoint pragma)
// ---------------------------------------------------------------
_, _ = be.App.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
_, _ = be.App.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
// copy the rest of the pb_data
// ---------------------------------------------------------------
err = zipper.copyDirToZip(os.DirFS(be.App.DataDir()), excluded)
if err != nil {
return err
}
return zipper.close()
}
// normalize the provided file path to always end with forward slash
func normalizePathExclude(filePath string) string {
return path.Clean(filePath) + "/"
}
type zipWriter struct {
mu sync.Mutex
w *zip.Writer
f *os.File
closed bool
}
func newZipWriter(zipFilePath string) (*zipWriter, error) {
f, err := os.Create(zipFilePath)
if err != nil {
return nil, err
}
w := zip.NewWriter(f)
w.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(out, flate.BestSpeed)
})
return &zipWriter{
w: w,
f: f,
}, nil
}
func (z *zipWriter) close() error {
z.mu.Lock()
defer z.mu.Unlock()
if z.closed {
return nil
}
z.closed = true
return errors.Join(z.w.Close(), z.f.Close())
}
func (z *zipWriter) copyFileToZip(localPath string, zipPath string) error {
info, err := os.Stat(localPath)
if err != nil {
return err
}
if info.IsDir() {
return errIsDir
}
h, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
h.Name = zipPath
h.Method = zip.Deflate
z.mu.Lock()
defer z.mu.Unlock()
if z.closed {
return errors.New("zip writer is already closed")
}
fw, err := z.w.CreateHeader(h)
if err != nil {
return err
}
f, err := os.Open(localPath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(fw, f)
return err
}
func (z *zipWriter) copyDirToZip(fsys fs.FS, excludedPrefixes *store.Store[string, struct{}]) error {
return fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// skip excluded prefixes
if excludedPrefixes != nil {
check := normalizePathExclude(name)
prefixes := excludedPrefixes.Keys() // refetch to avoid races
for _, prefix := range prefixes {
if strings.HasPrefix(check, prefix) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
}
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
h, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
h.Name = name
h.Method = zip.Deflate
z.mu.Lock()
defer z.mu.Unlock()
if z.closed {
// note: fs.WalkDir perform direct comparison with the value
return fs.SkipAll
}
fw, err := z.w.CreateHeader(h)
if err != nil {
return err
}
f, err := fsys.Open(name)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(fw, f)
return err
})
}
@@ -9,117 +9,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"time"
"github.com/pocketbase/pocketbase/tools/archive" "github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/osutils" "github.com/pocketbase/pocketbase/tools/osutils"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
) )
const (
StoreKeyActiveBackup = "@activeBackup"
)
// CreateBackup creates a new backup of the current app pb_data directory.
//
// If name is empty, it will be autogenerated.
// If backup with the same name exists, the new backup file will replace it.
//
// The backup is executed within a transaction, meaning that new writes
// will be temporary "blocked" until the backup file is generated.
//
// To safely perform the backup, it is recommended to have free disk space
// for at least 2x the size of the pb_data directory.
//
// By default backups are stored in pb_data/backups
// (the backups directory itself is excluded from the generated backup).
//
// When using S3 storage for the uploaded collection files, you have to
// take care manually to backup those since they are not part of the pb_data.
//
// 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.Store().Has(StoreKeyActiveBackup) {
return errors.New("try again later - another backup/restore operation has already been started")
}
app.Store().Set(StoreKeyActiveBackup, name)
defer app.Store().Remove(StoreKeyActiveBackup)
event := new(BackupEvent)
event.App = app
event.Context = ctx
event.Name = name
// default root dir entries to exclude from the backup generation
event.Exclude = []string{
LocalBackupsDirName,
LocalTempDirName,
LocalNotifyDirName,
LocalAutocertCacheDirName,
lostFoundDirName,
}
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
// generate a default name if missing
if e.Name == "" {
e.Name = generateBackupName(e.App, "pb_backup_")
}
// make sure that the special temp directory exists
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
//
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
// ---
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
createErr := e.App.RunInTransaction(func(txApp App) error {
return txApp.AuxRunInTransaction(func(txApp App) error {
// run manual checkpoint and truncate the WAL files
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
})
})
if createErr != nil {
return createErr
}
defer os.Remove(tempPath)
// persist the backup in the backups filesystem
// ---
fsys, err := e.App.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(e.Context)
file, err := filesystem.NewFileFromPath(tempPath)
if err != nil {
return err
}
file.OriginalName = e.Name
file.Name = file.OriginalName
if err := fsys.UploadFile(file, file.Name); err != nil {
return err
}
return nil
})
}
// RestoreBackup restores the backup with the specified name and restarts // RestoreBackup restores the backup with the specified name and restarts
// the current running application process. // the current running application process.
// //
@@ -299,124 +194,3 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
return nil return nil
}) })
} }
// registerAutobackupHooks registers the autobackup app serve hooks.
func (app *BaseApp) registerAutobackupHooks() {
const jobId = "__pbAutoBackup__"
loadJob := func() {
rawSchedule := app.Settings().Backups.Cron
if rawSchedule == "" {
app.Cron().Remove(jobId)
return
}
app.Cron().Add(jobId, rawSchedule, func() {
const autoPrefix = "@auto_pb_backup_"
name := generateBackupName(app, autoPrefix)
if err := app.CreateBackup(context.Background(), name); err != nil {
app.Logger().Error(
"[Backup cron] Failed to create backup",
slog.String("name", name),
slog.String("error", err.Error()),
)
alertError := sendSystemAlertToAllSuperusers(
app,
"Autobackup failure",
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
)
if alertError != nil {
app.Logger().Warn(
"[Backup cron] Failed to send backup error alerts",
slog.String("name", name),
slog.String("error", alertError.Error()),
)
}
}
maxKeep := app.Settings().Backups.CronMaxKeep
if maxKeep == 0 {
return // no explicit limit
}
fsys, err := app.NewBackupsFilesystem()
if err != nil {
app.Logger().Error(
"[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.Logger().Error(
"[Backup cron] Failed to list autogenerated backups",
slog.String("error", err.Error()),
)
return
}
if maxKeep >= len(files) {
return // nothing to remove
}
// sort desc
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime.After(files[j].ModTime)
})
// keep only the most recent n auto backup files
toRemove := files[maxKeep:]
for _, f := range toRemove {
if err := fsys.Delete(f.Key); err != nil {
app.Logger().Error(
"[Backup cron] Failed to remove old autogenerated backup",
slog.String("key", f.Key),
slog.String("error", err.Error()),
)
}
}
})
}
app.OnBootstrap().BindFunc(func(e *BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
loadJob()
return nil
})
app.OnSettingsReload().BindFunc(func(e *SettingsReloadEvent) error {
if err := e.Next(); err != nil {
return err
}
loadJob()
return nil
})
}
func generateBackupName(app App, prefix string) string {
appName := inflector.Snakecase(app.Settings().Meta.AppName)
if len(appName) > 50 {
appName = appName[:50]
}
return fmt.Sprintf(
"%s%s_%s.zip",
prefix,
appName,
time.Now().UTC().Format("20060102150405"),
)
}
@@ -126,11 +126,7 @@ func verifyBackupContent(app core.App, path string) error {
expectedRootEntries := []string{ expectedRootEntries := []string{
"storage", "storage",
"data.db", "data.db",
"data.db-shm",
"data.db-wal",
"auxiliary.db", "auxiliary.db",
"auxiliary.db-shm",
"auxiliary.db-wal",
".gitignore", ".gitignore",
} }
+159 -42
View File
@@ -12,6 +12,7 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
@@ -44,6 +45,9 @@ const (
// @todo consider removing after backups refactoring // @todo consider removing after backups refactoring
lostFoundDirName string = "lost+found" lostFoundDirName string = "lost+found"
dataDBFilename string = "data.db"
auxDBFilename string = "auxiliary.db"
) )
// FilesManager defines an interface with common methods that files manager models should implement. // FilesManager defines an interface with common methods that files manager models should implement.
@@ -87,6 +91,7 @@ type BaseApp struct {
// app event hooks // app event hooks
onBootstrap *hook.Hook[*BootstrapEvent] onBootstrap *hook.Hook[*BootstrapEvent]
onBootstrapClear *hook.Hook[*BootstrapEvent]
onServe *hook.Hook[*ServeEvent] onServe *hook.Hook[*ServeEvent]
onTerminate *hook.Hook[*TerminateEvent] onTerminate *hook.Hook[*TerminateEvent]
onBackupCreate *hook.Hook[*BackupEvent] onBackupCreate *hook.Hook[*BackupEvent]
@@ -146,6 +151,17 @@ type BaseApp struct {
onMailerRecordOTPSend *hook.Hook[*MailerRecordEvent] onMailerRecordOTPSend *hook.Hook[*MailerRecordEvent]
onMailerRecordAuthAlertSend *hook.Hook[*MailerRecordEvent] onMailerRecordAuthAlertSend *hook.Hook[*MailerRecordEvent]
// filesystem event hooks
//
// @todo 1:
// intentionally not exposed since the events are too "chatty" and
// can cause unnecessary userland tests breaking changes;
// reevaluate once refactoring the file_field
//
// @todo 2: if exposed consider registering the same for the backup filesystem
_onFilesystemNewWriter *hook.Hook[*FilesystemNewWriterEvent]
_onFilesystemDelete *hook.Hook[*FilesystemDeleteEvent]
// realtime api event hooks // realtime api event hooks
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent] onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent]
onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent] onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent]
@@ -235,6 +251,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
func (app *BaseApp) initHooks() { func (app *BaseApp) initHooks() {
// app event hooks // app event hooks
app.onBootstrap = &hook.Hook[*BootstrapEvent]{} app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
app.onBootstrapClear = &hook.Hook[*BootstrapEvent]{}
app.onServe = &hook.Hook[*ServeEvent]{} app.onServe = &hook.Hook[*ServeEvent]{}
app.onTerminate = &hook.Hook[*TerminateEvent]{} app.onTerminate = &hook.Hook[*TerminateEvent]{}
app.onBackupCreate = &hook.Hook[*BackupEvent]{} app.onBackupCreate = &hook.Hook[*BackupEvent]{}
@@ -294,6 +311,10 @@ func (app *BaseApp) initHooks() {
app.onMailerRecordOTPSend = &hook.Hook[*MailerRecordEvent]{} app.onMailerRecordOTPSend = &hook.Hook[*MailerRecordEvent]{}
app.onMailerRecordAuthAlertSend = &hook.Hook[*MailerRecordEvent]{} app.onMailerRecordAuthAlertSend = &hook.Hook[*MailerRecordEvent]{}
// filesystem event hooks
app._onFilesystemNewWriter = &hook.Hook[*FilesystemNewWriterEvent]{}
app._onFilesystemDelete = &hook.Hook[*FilesystemDeleteEvent]{}
// realtime API event hooks // realtime API event hooks
app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{} app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{} app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
@@ -387,14 +408,14 @@ func (app *BaseApp) IsBootstrapped() bool {
// Bootstrap initializes the application // Bootstrap initializes the application
// (aka. create data dir, open db connections, load settings, etc.). // (aka. create data dir, open db connections, load settings, etc.).
// //
// It will call ResetBootstrapState() if the application was already bootstrapped. // It calls ClearBootstrap() if the application was already bootstrapped.
func (app *BaseApp) Bootstrap() error { func (app *BaseApp) Bootstrap() error {
event := &BootstrapEvent{} event := &BootstrapEvent{}
event.App = app event.App = app
err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error { err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
// clear resources of previous core state (if any) // clear previous bootstrap state (if any)
if err := app.ResetBootstrapState(); err != nil { if err := app.ClearBootstrap(); err != nil {
return err return err
} }
@@ -442,14 +463,27 @@ func (app *BaseApp) Bootstrap() error {
return err return err
} }
type closer interface { // Deprecated: use [ClearBootstrap].
Close() error func (app *BaseApp) ResetBootstrapState() error {
return app.ClearBootstrap()
} }
// ResetBootstrapState releases the initialized core app resources // ClearBootstrap releases the initialized core app resources
// (closing db connections, stopping cron ticker, etc.). // (closing db connections, stopping cron ticker, etc.).
func (app *BaseApp) ResetBootstrapState() error { //
app.Cron().Stop() // This method is no-op if the application is not bootstrapped yet.
func (app *BaseApp) ClearBootstrap() error {
if !app.IsBootstrapped() {
return nil
}
event := &BootstrapEvent{}
event.App = app
return app.OnBootstrapClear().Trigger(event, func(e *BootstrapEvent) error {
type closer interface {
Close() error
}
var errs []error var errs []error
@@ -477,6 +511,7 @@ func (app *BaseApp) ResetBootstrapState() error {
} }
return nil return nil
})
} }
// DB returns the default app data.db builder instance. // DB returns the default app data.db builder instance.
@@ -712,9 +747,10 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
// //
// NB! Make sure to call Close() on the returned result // NB! Make sure to call Close() on the returned result
// after you are done working with it. // after you are done working with it.
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) { func (app *BaseApp) NewFilesystem() (fsys *filesystem.System, err error) {
if app.settings != nil && app.settings.S3.Enabled { if app.settings != nil && app.settings.S3.Enabled {
return filesystem.NewS3( // S3
fsys, err = filesystem.NewS3(
app.settings.S3.Bucket, app.settings.S3.Bucket,
app.settings.S3.Region, app.settings.S3.Region,
app.settings.S3.Endpoint, app.settings.S3.Endpoint,
@@ -722,10 +758,41 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
app.settings.S3.Secret, app.settings.S3.Secret,
app.settings.S3.ForcePathStyle, app.settings.S3.ForcePathStyle,
) )
} else {
// local filesystem
fsys, err = filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
if err != nil {
return nil, err
} }
// fallback to local filesystem // attach delete hook
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName)) if app._onFilesystemDelete.Length() > 0 {
fsys.OnDelete().BindFunc(func(originalEvent *filesystem.DeleteEvent) error {
appEvent := new(FilesystemDeleteEvent)
appEvent.DeleteEvent = originalEvent
appEvent.App = app
return app._onFilesystemDelete.Trigger(appEvent, func(fde *FilesystemDeleteEvent) error {
return originalEvent.Next()
})
})
}
// attach write hook
if app._onFilesystemNewWriter.Length() > 0 {
fsys.OnNewWriter().BindFunc(func(originalEvent *filesystem.NewWriterEvent) error {
appEvent := new(FilesystemNewWriterEvent)
appEvent.NewWriterEvent = originalEvent
appEvent.App = app
return app._onFilesystemNewWriter.Trigger(appEvent, func(fwe *FilesystemNewWriterEvent) error {
return originalEvent.Next()
})
})
}
return fsys, nil
} }
// NewBackupsFilesystem creates a new local or S3 filesystem instance // NewBackupsFilesystem creates a new local or S3 filesystem instance
@@ -767,7 +834,7 @@ func (app *BaseApp) Restart() error {
event.IsRestart = true event.IsRestart = true
return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error { return app.OnTerminate().Trigger(event, func(e *TerminateEvent) error {
_ = e.App.ResetBootstrapState() _ = e.App.ClearBootstrap()
// attempt to restart the bootstrap process in case execve returns an error for some reason // attempt to restart the bootstrap process in case execve returns an error for some reason
defer func() { defer func() {
@@ -810,6 +877,10 @@ func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
return app.onBootstrap return app.onBootstrap
} }
func (app *BaseApp) OnBootstrapClear() *hook.Hook[*BootstrapEvent] {
return app.onBootstrapClear
}
func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] { func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
return app.onServe return app.onServe
} }
@@ -1016,6 +1087,18 @@ func (app *BaseApp) OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook
return hook.NewTaggedHook(app.onMailerRecordAuthAlertSend, tags...) return hook.NewTaggedHook(app.onMailerRecordAuthAlertSend, tags...)
} }
// -------------------------------------------------------------------
// Filesystem event hooks
// -------------------------------------------------------------------
func (app *BaseApp) onFilesystemNewWriter() *hook.Hook[*FilesystemNewWriterEvent] {
return app._onFilesystemNewWriter
}
func (app *BaseApp) onFilesystemDelete() *hook.Hook[*FilesystemDeleteEvent] {
return app._onFilesystemDelete
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Realtime API event hooks // Realtime API event hooks
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -1173,7 +1256,7 @@ func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
func (app *BaseApp) initDataDB() error { func (app *BaseApp) initDataDB() error {
dbPath := filepath.Join(app.DataDir(), "data.db") dbPath := filepath.Join(app.DataDir(), dataDBFilename)
concurrentDB, err := app.config.DBConnect(dbPath) concurrentDB, err := app.config.DBConnect(dbPath)
if err != nil { if err != nil {
@@ -1235,7 +1318,7 @@ func normalizeSQLLog(sql string) string {
func (app *BaseApp) initAuxDB() error { func (app *BaseApp) initAuxDB() error {
// note: renamed to "auxiliary" because "aux" is a reserved Windows filename // note: renamed to "auxiliary" because "aux" is a reserved Windows filename
// (see https://github.com/pocketbase/pocketbase/issues/5607) // (see https://github.com/pocketbase/pocketbase/issues/5607)
dbPath := filepath.Join(app.DataDir(), "auxiliary.db") dbPath := filepath.Join(app.DataDir(), auxDBFilename)
concurrentDB, err := app.config.DBConnect(dbPath) concurrentDB, err := app.config.DBConnect(dbPath)
if err != nil { if err != nil {
@@ -1351,7 +1434,15 @@ func (app *BaseApp) registerBaseHooks() {
Id: "__pbCronStart__", Id: "__pbCronStart__",
Func: func(e *ServeEvent) error { Func: func(e *ServeEvent) error {
app.Cron().Start() app.Cron().Start()
return e.Next()
},
Priority: 999,
})
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
Id: "__pbCronStop__",
Func: func(e *BootstrapEvent) error {
app.Cron().Stop()
return e.Next() return e.Next()
}, },
Priority: 999, Priority: 999,
@@ -1408,34 +1499,23 @@ func getLoggerMinLevel(app App) slog.Level {
} }
func (app *BaseApp) initLogger() error { func (app *BaseApp) initLogger() error {
var stopped atomic.Bool
duration := 3 * time.Second duration := 3 * time.Second
ticker := time.NewTicker(duration) ticker := time.NewTicker(duration)
done := make(chan bool, 1)
handler := logger.NewBatchHandler(logger.BatchOptions{ done := make(chan struct{}, 1)
Level: getLoggerMinLevel(app),
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 runLogsWrite := func(logs []*logger.Log) {
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 { if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
return nil return
} }
// write the accumulated logs // 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) //
// note: based on several local tests there is no
// significant performance difference between small number
// of separate write queries vs 1 big INSERT
app.AuxRunInTransaction(func(txApp App) error { app.AuxRunInTransaction(func(txApp App) error {
model := &Log{} model := &Log{}
for _, l := range logs { for _, l := range logs {
@@ -1453,6 +1533,39 @@ func (app *BaseApp) initLogger() error {
return nil return nil
}) })
}
handler := logger.NewBatchHandler(logger.BatchOptions{
Level: getLoggerMinLevel(app),
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
}
}
if !stopped.Load() {
ticker.Reset(duration)
}
return app.Settings().Logs.MaxDays > 0
},
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
// don't block and wait for the write transaction to complete
// when we can't be sure if the logs write wasn't triggered while
// inside another AUX db transaction (ticker or batch threshold reached)
// which can block indefinitely and cause deadlock
// (https://github.com/pocketbase/pocketbase/issues/7836)
shouldBlock, _ := ctx.Value(logger.BlockKey).(bool)
if shouldBlock {
runLogsWrite(logs)
} else {
routine.FireAndForget(func() { runLogsWrite(logs) })
}
return nil return nil
}, },
@@ -1473,17 +1586,21 @@ func (app *BaseApp) initLogger() error {
app.logger = slog.New(handler) app.logger = slog.New(handler)
// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls // attempt to write all queued logs before clearing the application bootstrap state
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{ app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
Id: "__pbAppLoggerOnTerminate__", Id: "__pbAppLoggerFlushBeforeStop__",
Func: func(e *TerminateEvent) error { Func: func(e *BootstrapEvent) error {
handler.WriteAll(context.Background()) // extra precaution in case the hook was manually triggered while inside aux db transaction
_, isTx := e.App.AuxNonconcurrentDB().(*dbx.Tx)
ctx := context.WithValue(context.Background(), logger.BlockKey, !isTx)
handler.WriteAll(ctx)
stopped.Store(true)
ticker.Stop() ticker.Stop()
// don't block in case OnTerminate is triggered more than once // don't block in case the hook is triggered more than once
select { select {
case done <- true: case done <- struct{}{}:
default: default:
} }
+141 -36
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"slices" "slices"
"testing" "testing"
"testing/synctest"
"time" "time"
_ "unsafe" _ "unsafe"
@@ -64,7 +65,7 @@ func TestBaseAppBootstrap(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
if app.IsBootstrapped() { if app.IsBootstrapped() {
t.Fatal("Didn't expect the application to be bootstrapped.") t.Fatal("Didn't expect the application to be bootstrapped.")
@@ -114,7 +115,7 @@ func TestBaseAppBootstrap(t *testing.T) {
runNilChecks(nilChecksBeforeReset) runNilChecks(nilChecksBeforeReset)
// reset // reset
if err := app.ResetBootstrapState(); err != nil { if err := app.ClearBootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -140,7 +141,7 @@ func TestNewBaseAppTx(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -184,7 +185,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
DataDir: testDataDir, DataDir: testDataDir,
EncryptionEnv: "pb_test_env", EncryptionEnv: "pb_test_env",
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
client1 := app.NewMailClient() client1 := app.NewMailClient()
m1, ok := client1.(*mailer.Sendmail) m1, ok := client1.(*mailer.Sendmail)
@@ -214,7 +215,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
// local // local
local, localErr := app.NewFilesystem() local, localErr := app.NewFilesystem()
@@ -243,7 +244,7 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
app := core.NewBaseApp(core.BaseAppConfig{ app := core.NewBaseApp(core.BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
// local // local
local, localErr := app.NewBackupsFilesystem() local, localErr := app.NewBackupsFilesystem()
@@ -265,20 +266,9 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
} }
} }
func TestBaseAppLoggerWrites(t *testing.T) { const logsThreshold = 200
t.Parallel()
app, _ := tests.NewTestApp() func assertLogsCount(t *testing.T, app core.App, expected int) {
defer app.Cleanup()
// reset
if err := app.DeleteOldLogs(time.Now()); err != nil {
t.Fatal(err)
}
const logsThreshold = 200
totalLogs := func(app core.App, t *testing.T) int {
var total int var total int
err := app.LogQuery().Select("count(*)").Row(&total) err := app.LogQuery().Select("count(*)").Row(&total)
@@ -286,47 +276,162 @@ func TestBaseAppLoggerWrites(t *testing.T) {
t.Fatalf("Failed to fetch total logs: %v", err) t.Fatalf("Failed to fetch total logs: %v", err)
} }
return total if total != expected {
t.Fatalf("Expected %d log(s), got %d", expected, total)
}
}
func TestBaseAppLoggerWrites(t *testing.T) {
t.Parallel()
// note: outside of synctest because the bootstrap tickers could deadlock
app, _ := tests.NewTestApp()
defer app.Cleanup()
// clear old logs
err := app.DeleteOldLogs(time.Now())
if err != nil {
t.Fatal(err)
} }
t.Run("disabled logs retention", func(t *testing.T) { t.Run("disabled logs retention", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
app.Settings().Logs.MaxDays = 0 app.Settings().Logs.MaxDays = 0
for i := 0; i < logsThreshold+1; i++ { for i := 0; i < logsThreshold+1; i++ {
app.Logger().Error("test") app.Logger().Error("test")
} }
if total := totalLogs(app, t); total != 0 { // short delay for the non-blocking write goroutine
t.Fatalf("Expected no logs, got %d", total) synctest.Sleep(time.Nanosecond)
}
assertLogsCount(t, app, 0)
})
}) })
t.Run("test batch logs writes", func(t *testing.T) { t.Run("test batch logs writes", func(t *testing.T) {
app.Settings().Logs.MaxDays = 1 synctest.Test(t, func(t *testing.T) {
app.Settings().Logs.MaxDays = 2
for i := 0; i < logsThreshold-1; i++ { for i := 0; i < logsThreshold-1; i++ {
app.Logger().Error("test") app.Logger().Error("test")
} }
if total := totalLogs(app, t); total != 0 { // short delay for the non-blocking write goroutine
t.Fatalf("Expected no logs, got %d", total) synctest.Sleep(time.Nanosecond)
}
// should trigger batch write // below threshold
assertLogsCount(t, app, 0)
// threshold reached -> should trigger batch write
app.Logger().Error("test") app.Logger().Error("test")
// should be added for the next batch write // should be skipped from this batch and added for the next
app.Logger().Error("test") app.Logger().Error("test")
if total := totalLogs(app, t); total != logsThreshold { // short delay for the non-blocking write goroutine
t.Fatalf("Expected %d logs, got %d", logsThreshold, total) synctest.Sleep(time.Nanosecond)
assertLogsCount(t, app, logsThreshold)
// note: we can't test the flush timer here because the ticker
// was started out of the synctest buble to avoid deadlocks
// (see TestBaseAppLoggerWritesAwaited for a flaky but real timer test)
})
})
}
func TestBaseAppLoggerWritesAwaited(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
// clear old logs
err := app.DeleteOldLogs(time.Now())
if err != nil {
t.Fatal(err)
} }
// wait for ~3 secs to check the timer trigger // enable logs persistence
time.Sleep(3200 * time.Millisecond) app.Settings().Logs.MaxDays = 1
if total := totalLogs(app, t); total != logsThreshold+1 { err = app.Save(app.Settings())
t.Fatalf("Expected %d logs, got %d", logsThreshold+1, total) if err != nil {
t.Fatal(err)
} }
t.Run("flush on timer tick", func(t *testing.T) {
timeout := time.After(5 * time.Second)
done := make(chan struct{})
logsHook := app.OnModelAfterCreateSuccess("_logs")
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
done <- struct{}{}
return e.Next()
})
defer logsHook.Unbind(hookId)
app.Logger().Error("test")
// short wait to ensure that there is no non-blocking write
time.Sleep(500 * time.Millisecond)
assertLogsCount(t, app, 0)
// wait for the ticker to write the db record
select {
case <-timeout:
t.Fatal("ticker wait timeout")
case <-done:
}
assertLogsCount(t, app, 1)
})
t.Run("before ClearBootstrap flush", func(t *testing.T) {
app.Logger().Error("test")
app.Bootstrap()
assertLogsCount(t, app, 2)
})
t.Run("batch flush inside aux transaction shouldn't hang", func(t *testing.T) {
timeout := time.After(1 * time.Second)
done := make(chan struct{})
totalCreated := 0
logsHook := app.OnModelAfterCreateSuccess("_logs")
hookId := logsHook.BindFunc(func(e *core.ModelEvent) error {
totalCreated++
if totalCreated == 200 {
done <- struct{}{}
}
return e.Next()
})
defer logsHook.Unbind(hookId)
app.AuxRunInTransaction(func(txApp core.App) error {
for range logsThreshold {
txApp.Logger().Error("test")
}
return nil
})
// wait for the non-blocking write
select {
case <-timeout:
t.Fatal("non-blocking write timeout")
case <-done:
}
assertLogsCount(t, app, 202)
// force clear to ensure that there are no other logs
app.Bootstrap()
assertLogsCount(t, app, 202)
}) })
} }
@@ -369,7 +474,7 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
DataDir: testDataDir, DataDir: testDataDir,
IsDev: s.isDev, IsDev: s.isDev,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
+7 -2
View File
@@ -4,7 +4,8 @@ import (
"cmp" "cmp"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -186,7 +187,11 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
) )
if err := validator.run(); err != nil { if err := validator.run(); err != nil {
// serialize the validation error(s) // serialize the validation error(s)
serializedErr, _ := json.MarshalIndent(err, "", " ") serializedErr, _ := json.Marshal(
err,
jsontext.WithIndentPrefix(""),
jsontext.WithIndent(" "),
)
return validation.Errors{"collections": validation.NewError( return validation.Errors{"collections": validation.NewError(
"validation_collections_import_failure", "validation_collections_import_failure",
+2 -2
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"strings" "strings"
"testing" "testing"
@@ -449,7 +449,7 @@ func TestImportCollectionsCreateRules(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(collection) raw, err := json.Marshal(collection, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+10 -9
View File
@@ -1,7 +1,7 @@
package core package core
import ( import (
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -522,8 +522,6 @@ func (m *Collection) unmarshalRawOptions() error {
// For new/"blank" Collection models it replaces the model with a factory // For new/"blank" Collection models it replaces the model with a factory
// instance and then unmarshal the provided data one on top of it. // instance and then unmarshal the provided data one on top of it.
func (m *Collection) UnmarshalJSON(b []byte) error { func (m *Collection) UnmarshalJSON(b []byte) error {
type alias *Collection
// initialize the default fields // initialize the default fields
// (e.g. in case the collection was NOT created using the designated factories) // (e.g. in case the collection was NOT created using the designated factories)
if m.IsNew() && m.Type == "" { if m.IsNew() && m.Type == "" {
@@ -540,7 +538,8 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
*m = *blank *m = *blank
} }
return json.Unmarshal(b, alias(m)) type alias Collection
return json.Unmarshal(b, (*alias)(m))
} }
// MarshalJSON implements the [json.Marshaler] interface. // MarshalJSON implements the [json.Marshaler] interface.
@@ -550,10 +549,12 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
func (m Collection) MarshalJSON() ([]byte, error) { func (m Collection) MarshalJSON() ([]byte, error) {
switch m.Type { switch m.Type {
case CollectionTypeView: case CollectionTypeView:
return json.Marshal(struct { alias := struct {
baseCollection baseCollection
collectionViewOptions collectionViewOptions
}{m.baseCollection, m.collectionViewOptions}) }{m.baseCollection, m.collectionViewOptions}
return json.Marshal(alias, json.Deterministic(true))
case CollectionTypeAuth: case CollectionTypeAuth:
alias := struct { alias := struct {
baseCollection baseCollection
@@ -582,15 +583,15 @@ func (m Collection) MarshalJSON() ([]byte, error) {
alias.OAuth2.Providers = redactedProviders alias.OAuth2.Providers = redactedProviders
} }
return json.Marshal(alias) return json.Marshal(alias, json.Deterministic(true))
default: default:
return json.Marshal(m.baseCollection) return json.Marshal(m.baseCollection, json.Deterministic(true))
} }
} }
// String returns a string representation of the current collection. // String returns a string representation of the current collection.
func (m Collection) String() string { func (m Collection) String() string {
raw, _ := json.Marshal(m) raw, _ := m.MarshalJSON()
return string(raw) return string(raw)
} }
+68
View File
@@ -1,6 +1,9 @@
package core package core
import ( import (
"encoding/json/v2"
"errors"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -409,6 +412,71 @@ type OAuth2Config struct {
Enabled bool `form:"enabled" json:"enabled"` Enabled bool `form:"enabled" json:"enabled"`
} }
// UnmarshalJSON implements the [json.Unmarshaler] interface.
//
// The main difference from the standard unmarshalization is that
// instead of replacing the entire providers config slice, we ensure
// that partially submitted provider data (e.g. without clientSecret)
// is merged on per config level based on the provider name
// (https://github.com/pocketbase/pocketbase/issues/7815).
func (c *OAuth2Config) UnmarshalJSON(b []byte) error {
originalProviders := slices.Clone(c.Providers)
type alias OAuth2Config
err := json.Unmarshal(b, (*alias)(c))
if err != nil {
return err
}
if len(c.Providers) == 0 {
return nil
}
// unmarshal again but this time into a plain array of objects
// so that we have only the submitted fields and no zero defaults
plain := struct {
Providers []map[string]any `json:"providers"`
}{}
err = json.Unmarshal(b, &plain)
if err != nil {
return err
}
// no providers were submitted
if len(plain.Providers) == 0 {
return nil
}
if len(c.Providers) != len(plain.Providers) {
return errors.New("the length of the plain unmarshalized providers and the ones from the config doesn't match")
}
ProvidersMergeLoop:
for i, plain := range plain.Providers {
for _, original := range originalProviders {
if original.Name == plain["name"] {
raw, err := json.Marshal(plain)
if err != nil {
return err
}
// unmarshal the new plain data on top of the original one
err = json.Unmarshal(raw, &original)
if err != nil {
return err
}
// reassigne to the updated original
c.Providers[i] = original
continue ProvidersMergeLoop
}
}
}
return nil
}
// GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name. // GetProviderConfig returns the first OAuth2ProviderConfig that matches the specified name.
// //
// Returns false and zero config if no such provider is available in c.Providers. // Returns false and zero config if no such provider is available in c.Providers.
+73 -3
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@@ -712,6 +712,76 @@ func TestPasswordAuthConfigValidate(t *testing.T) {
} }
} }
func TestOAuth2ConfigUnmarshalJSON(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
newJSON string
expected string
}{
{
"missing",
`{
"enabled": true,
"mappedFields": {"username": "username_test"}
}`,
`{"providers":[{"pkce":null,"name":"a","clientId":"a_clientId","clientSecret":"a_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}},{"pkce":null,"name":"b","clientId":"b_clientId","clientSecret":"b_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}}],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
},
{
"empty",
`{
"enabled": true,
"mappedFields": {"username": "username_test"},
"providers": []
}`,
`{"providers":[],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
},
{
"non-empty",
`{
"enabled": true,
"mappedFields": {"username": "username_test"},
"providers": [
{"name": "c", "clientId": "c_clientId", "clientSecret": "c_clientSecret"},
{"name": "a", "displayName": "a_displayName"}
]
}`,
`{"providers":[{"pkce":null,"name":"c","clientId":"c_clientId","clientSecret":"c_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"","extra":{}},{"pkce":null,"name":"a","clientId":"a_clientId","clientSecret":"a_clientSecret","authURL":"","tokenURL":"","userInfoURL":"","displayName":"a_displayName","extra":{}}],"mappedFields":{"id":"","name":"name_test","username":"username_test","avatarURL":""},"enabled":true}`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := core.OAuth2Config{
Enabled: false,
MappedFields: core.OAuth2KnownFields{
Name: "name_test",
},
Providers: []core.OAuth2ProviderConfig{
{Name: "a", ClientId: "a_clientId", ClientSecret: "a_clientSecret"},
{Name: "b", ClientId: "b_clientId", ClientSecret: "b_clientSecret"},
},
}
err := json.Unmarshal([]byte(s.newJSON), &config)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(config, json.Deterministic(true))
if err != nil {
t.Fatal(err)
}
rawStr := string(raw)
if rawStr != s.expected {
t.Fatalf("Expected OAuth2ProviderConfig\n%s\ngot\n%s", s.expected, rawStr)
}
})
}
}
func TestOAuth2ConfigGetProviderConfig(t *testing.T) { func TestOAuth2ConfigGetProviderConfig(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
@@ -1016,8 +1086,8 @@ func TestOAuth2ProviderConfigInitProvider(t *testing.T) {
t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE()) t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
} }
rawMeta, _ := json.Marshal(provider.Extra()) rawMeta, _ := json.Marshal(provider.Extra(), json.Deterministic(true))
expectedMeta, _ := json.Marshal(s.expectedConfig.Extra) expectedMeta, _ := json.Marshal(s.expectedConfig.Extra, json.Deterministic(true))
if !bytes.Equal(rawMeta, expectedMeta) { if !bytes.Equal(rawMeta, expectedMeta) {
t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE()) t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
} }
+4 -4
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -618,7 +618,7 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rawResult, err := json.Marshal(collection) rawResult, err := json.Marshal(collection, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -829,7 +829,7 @@ func TestCollectionDBExport(t *testing.T) {
}, },
{ {
core.CollectionTypeAuth, core.CollectionTypeAuth,
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`, `{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":[],"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":[]},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
}, },
} }
@@ -860,7 +860,7 @@ func TestCollectionDBExport(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(result) raw, err := json.Marshal(result, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+3 -3
View File
@@ -3,7 +3,7 @@ package core
import ( import (
"bytes" "bytes"
"database/sql" "database/sql"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -324,12 +324,12 @@ func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
f.SetId("") f.SetId("")
} }
encodedNewFields, err := json.Marshal(newFields) encodedNewFields, err := json.Marshal(newFields, json.Deterministic(true))
if err != nil { if err != nil {
return err return err
} }
encodedOldFields, err := json.Marshal(oldFields) encodedOldFields, err := json.Marshal(oldFields, json.Deterministic(true))
if err != nil { if err != nil {
return err return err
} }
+3 -3
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"testing" "testing"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@@ -278,12 +278,12 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
t.Fatalf("Failed to load record: %v", err) t.Fatalf("Failed to load record: %v", err)
} }
encodedResult, err := json.Marshal(result) encodedResult, err := json.Marshal(result, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to encode result: %v", err) t.Fatalf("Failed to encode result: %v", err)
} }
encodedExpectation, err := json.Marshal(s.expected) encodedExpectation, err := json.Marshal(s.expected, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to encode expectation: %v", err) t.Fatalf("Failed to encode expectation: %v", err)
} }
+1 -1
View File
@@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
// Note: the busy_timeout pragma must be first because // Note: the busy_timeout pragma must be first because
// the connection needs to be set to block on busy before WAL mode // the connection needs to be set to block on busy before WAL mode
// is set in case it hasn't been already set by another connection. // is set in case it hasn't been already set by another connection.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)" pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)&_defensive=1"
db, err := dbx.Open("sqlite", dbPath+pragmas) db, err := dbx.Open("sqlite", dbPath+pragmas)
if err != nil { if err != nil {
+6 -2
View File
@@ -3,7 +3,7 @@ package core_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"slices" "slices"
"testing" "testing"
@@ -120,7 +120,11 @@ func TestTableInfo(t *testing.T) {
t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) { t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
rows, _ := app.TableInfo(s.tableName) rows, _ := app.TableInfo(s.tableName)
raw, err := json.Marshal(rows) raw, err := json.Marshal(
rows,
json.Deterministic(true),
json.FormatNilSliceAsNull(true),
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+5 -5
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -183,7 +183,7 @@ func TestRequestEventRequestInfo(t *testing.T) {
t.Fatalf("Failed to resolve request info: %v", err) t.Fatalf("Failed to resolve request info: %v", err)
} }
raw, err := json.Marshal(info) raw, err := json.Marshal(info, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to serialize request info: %v", err) t.Fatalf("Failed to serialize request info: %v", err)
} }
@@ -205,7 +205,7 @@ func TestRequestEventRequestInfo(t *testing.T) {
t.Fatalf("Failed to resolve request info: %v", err) t.Fatalf("Failed to resolve request info: %v", err)
} }
raw, err := json.Marshal(info) raw, err := json.Marshal(info, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to serialize request info: %v", err) t.Fatalf("Failed to serialize request info: %v", err)
} }
@@ -308,7 +308,7 @@ func TestRequestInfoClone(t *testing.T) {
// check the original data // check the original data
// --- // ---
originalRaw, err := json.Marshal(info) originalRaw, err := json.Marshal(info, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to serialize original request info: %v", err) t.Fatalf("Failed to serialize original request info: %v", err)
} }
@@ -321,7 +321,7 @@ func TestRequestInfoClone(t *testing.T) {
// check the clone data // check the clone data
// --- // ---
cloneRaw, err := json.Marshal(clone) cloneRaw, err := json.Marshal(clone, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatalf("Failed to serialize clone request info: %v", err) t.Fatalf("Failed to serialize clone request info: %v", err)
} }
+19
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/pocketbase/pocketbase/tools/auth" "github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/router"
@@ -188,6 +189,24 @@ type MailerRecordEvent struct {
Meta map[string]any Meta map[string]any
} }
// -------------------------------------------------------------------
// Filesystem events data
// -------------------------------------------------------------------
type FilesystemNewWriterEvent struct {
hook.Event
*filesystem.NewWriterEvent
App App
}
type FilesystemDeleteEvent struct {
hook.Event
*filesystem.DeleteEvent
App App
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Model events data // Model events data
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+16 -16
View File
@@ -3,7 +3,7 @@ package core_test
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -98,7 +98,7 @@ func TestFileFieldPrepareValue(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
f1Raw, err := json.Marshal(f1) f1Raw, err := json.Marshal(f1, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -143,7 +143,7 @@ func TestFileFieldPrepareValue(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -212,7 +212,7 @@ func TestFileFieldDriverValue(t *testing.T) {
} }
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -699,7 +699,7 @@ func TestFileFieldFindGetter(t *testing.T) {
v := getter(record) v := getter(record)
raw, err := json.Marshal(v) raw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -816,7 +816,7 @@ func TestFileFieldFindSetter(t *testing.T) {
setter(record, s.value) setter(record, s.value)
raw, err := json.Marshal(record.Get(s.field.GetName())) raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -915,8 +915,8 @@ func TestFileFieldIntercept(t *testing.T) {
tests.TestValidationErrors(t, err, []string{"text"}) tests.TestValidationErrors(t, err, []string{"text"})
raw, _ := json.Marshal(record.GetRaw("file_many")) raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
expectedRaw, _ := json.Marshal([]any{f1.Name, f3}) expectedRaw, _ := json.Marshal([]any{f1.Name, f3}, json.Deterministic(true))
if !bytes.Equal(expectedRaw, raw) { if !bytes.Equal(expectedRaw, raw) {
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw) t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
} }
@@ -935,8 +935,8 @@ func TestFileFieldIntercept(t *testing.T) {
t.Fatalf("Expected save to succeed, got %v", err) t.Fatalf("Expected save to succeed, got %v", err)
} }
raw, _ := json.Marshal(record.GetRaw("file_many")) raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}) expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
if !bytes.Equal(expectedRaw, raw) { if !bytes.Equal(expectedRaw, raw) {
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw) t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
} }
@@ -956,8 +956,8 @@ func TestFileFieldIntercept(t *testing.T) {
t.Fatalf("Expected save to succeed, got %v", err) t.Fatalf("Expected save to succeed, got %v", err)
} }
raw, _ := json.Marshal(record.GetRaw("file_many")) raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}) expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
if !bytes.Equal(expectedRaw, raw) { if !bytes.Equal(expectedRaw, raw) {
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw) t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
} }
@@ -1067,8 +1067,8 @@ func TestFileFieldInterceptTx(t *testing.T) {
t.Fatalf("Expected save to succeed, got %v", err) t.Fatalf("Expected save to succeed, got %v", err)
} }
raw, _ := json.Marshal(record.GetRaw("file_many")) raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}) expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
if !bytes.Equal(expectedRaw, raw) { if !bytes.Equal(expectedRaw, raw) {
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw) t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
} }
@@ -1090,8 +1090,8 @@ func TestFileFieldInterceptTx(t *testing.T) {
t.Fatalf("Expected save to succeed, got %v", err) t.Fatalf("Expected save to succeed, got %v", err)
} }
raw, _ := json.Marshal(record.GetRaw("file_many")) raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}) expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
if !bytes.Equal(expectedRaw, raw) { if !bytes.Equal(expectedRaw, raw) {
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw) t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
} }
+2 -2
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"testing" "testing"
@@ -57,7 +57,7 @@ func TestGeoPointFieldPrepareValue(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(v) raw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+14
View File
@@ -2,6 +2,8 @@ package core
import ( import (
"context" "context"
"encoding/json/jsontext"
"encoding/json/v2"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -171,6 +173,18 @@ func (f *JSONField) ValidateValue(ctx context.Context, app App, record *Record)
return validation.NewError("validation_invalid_json", "Must be a valid json value") return validation.NewError("validation_invalid_json", "Must be a valid json value")
} }
// temp extra encoding/json/v2 check since the above validator is
// still using the v1 semantics
//
// @todo remove after updating the string validator
if len(raw) > 0 {
var dummy any
err := json.Unmarshal(raw, &dummy, jsontext.AllowInvalidUTF8(true))
if err != nil {
return validation.NewError("validation_invalid_json", "Must be a valid json value")
}
}
rawStr := strings.TrimSpace(raw.String()) rawStr := strings.TrimSpace(raw.String())
if f.Required && slices.Contains(emptyJSONValues, rawStr) { if f.Required && slices.Contains(emptyJSONValues, rawStr) {
+10
View File
@@ -101,6 +101,16 @@ func TestJSONFieldValidateValue(t *testing.T) {
}, },
true, true,
}, },
{
"invalid raw value (v2 semantics)",
&core.JSONField{Name: "test"},
func() *core.Record {
record := core.NewRecord(collection)
record.SetRaw("test", types.JSONRaw(`{"a": 1, "a": 2}`))
return record
},
true,
},
{ {
"zero field value (not required)", "zero field value (not required)",
&core.JSONField{Name: "test"}, &core.JSONField{Name: "test"},
+2 -2
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -496,7 +496,7 @@ func TestPasswordFieldFindSetter(t *testing.T) {
setter(record, s.value) setter(record, s.value)
raw, err := json.Marshal(record.Get(s.field.GetName())) raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+4 -4
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"testing" "testing"
@@ -119,7 +119,7 @@ func TestRelationFieldPrepareValue(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -181,7 +181,7 @@ func TestRelationFieldDriverValue(t *testing.T) {
} }
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -590,7 +590,7 @@ func TestRelationFieldFindSetter(t *testing.T) {
setter(record, s.value) setter(record, s.value)
raw, err := json.Marshal(record.Get(s.field.GetName())) raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+4 -4
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"testing" "testing"
@@ -119,7 +119,7 @@ func TestSelectFieldPrepareValue(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -181,7 +181,7 @@ func TestSelectFieldDriverValue(t *testing.T) {
} }
} }
vRaw, err := json.Marshal(v) vRaw, err := json.Marshal(v, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -503,7 +503,7 @@ func TestSelectFieldFindSetter(t *testing.T) {
setter(record, s.value) setter(record, s.value)
raw, err := json.Marshal(record.Get(s.field.GetName())) raw, err := json.Marshal(record.Get(s.field.GetName()), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
+4 -4
View File
@@ -2,7 +2,7 @@ package core
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"slices" "slices"
"strconv" "strconv"
@@ -277,7 +277,7 @@ func (l *FieldsList) add(pos int, newField Field) {
// String returns the string representation of the current list. // String returns the string representation of the current list.
func (l FieldsList) String() string { func (l FieldsList) String() string {
v, _ := json.Marshal(l) v, _ := l.MarshalJSON()
return string(v) return string(v)
} }
@@ -355,12 +355,12 @@ func (l FieldsList) MarshalJSON() ([]byte, error) {
wrapper = append(wrapper, data) wrapper = append(wrapper, data)
} }
return json.Marshal(wrapper) return json.Marshal(wrapper, json.Deterministic(true))
} }
// Value implements the [driver.Valuer] interface. // Value implements the [driver.Valuer] interface.
func (l FieldsList) Value() (driver.Value, error) { func (l FieldsList) Value() (driver.Value, error) {
data, err := json.Marshal(l) data, err := l.MarshalJSON()
return string(data), err return string(data), err
} }
+1 -1
View File
@@ -2,7 +2,7 @@ package core_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
+69 -2
View File
@@ -1,13 +1,23 @@
package core package core
import "github.com/pocketbase/pocketbase/tools/types" import (
"encoding/json/v2"
"github.com/pocketbase/pocketbase/tools/types"
)
var ( var (
_ Model = (*Log)(nil) _ Model = (*Log)(nil)
_ DBExporter = (*Log)(nil)
) )
const LogsTableName = "_logs" const LogsTableName = "_logs"
const (
defaultMaxLogDataSize = 16 << 10 // ~16kb
defaultMaxLogMessageSize = 8000
)
type Log struct { type Log struct {
BaseModel BaseModel
@@ -17,6 +27,63 @@ type Log struct {
Level int `db:"level" json:"level"` Level int `db:"level" json:"level"`
} }
func (m *Log) TableName() string { func (l *Log) TableName() string {
return LogsTableName return LogsTableName
} }
// DBExport prepares and exports the current log model for db persistence.
//
// It also truncates the log's message and data to ensure that it is
// under app.Settings().Logs.MaxDataSize.
func (l *Log) DBExport(app App) (map[string]any, error) {
result := map[string]any{
"id": l.Id,
"created": l.Created,
"level": l.Level,
}
// truncate the raw message bytes
// (this is expected to be very rare so it is ok even if multi-byte chars)
if int64(len(l.Message)) > defaultMaxLogMessageSize {
result["message"] = l.Message[:defaultMaxLogMessageSize]
} else {
result["message"] = l.Message
}
// @todo once added in the standard library consider replacing with
// WithByteLimit and WithDepthLimit as suggested in https://github.com/golang/go/issues/56733
if len(l.Data) == 0 {
result["data"] = l.Data
} else {
maxDataSize := app.Settings().Logs.MaxDataSize
if maxDataSize == 0 {
maxDataSize = defaultMaxLogDataSize
}
rawData, err := l.Data.MarshalJSON()
if err != nil {
return nil, err
}
if int64(len(rawData)) > maxDataSize {
truncatedData := types.JSONMap[any]{}
// ignore syntax errors in case of truncated incomplete json
//
// jsonv2 stream decodes and all "valid" attrs read up to the
// invalid part will be populated in truncatedData
_ = json.Unmarshal(rawData[:maxDataSize], &truncatedData)
truncatedData["__pb_truncated__"] = true
rawData, err = truncatedData.MarshalJSON()
if err != nil {
return nil, err
}
}
result["data"] = types.JSONRaw(rawData)
}
return result, nil
}
+119
View File
@@ -0,0 +1,119 @@
package core_test
import (
"bytes"
"encoding/json/v2"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestLogTableName(t *testing.T) {
t.Parallel()
var log core.Log
if name := log.TableName(); name != core.LogsTableName {
t.Fatalf("Expected Log table name %q, got %q", core.LogsTableName, name)
}
}
func TestLogDBExport(t *testing.T) {
t.Parallel()
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
date, err := types.ParseDateTime("2026-08-18 10:20:30.456Z")
if err != nil {
t.Fatal(err)
}
messageLimit := 8000
dataLimit := 16 << 10
scenarios := []struct {
name string
log core.Log
limit int64 // 0 -> use default
expectJSON string
}{
{
"empty log",
core.Log{},
0,
`{"created":"","data":{},"id":"","level":0,"message":""}`,
},
{
"with message and data below the default limits",
core.Log{
BaseModel: core.BaseModel{Id: "test_id"},
Created: date,
Level: 123,
Message: "test_message",
Data: types.JSONMap[any]{"a": "test1", "b": "test2"},
},
0,
`{"created":"2026-08-18 10:20:30.456Z","data":{"a":"test1","b":"test2"},"id":"test_id","level":123,"message":"test_message"}`,
},
{
"with message and data exactly the default limits",
core.Log{
BaseModel: core.BaseModel{Id: "test_id"},
Created: date,
Level: 123,
Message: strings.Repeat("a", messageLimit),
Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", dataLimit-32)},
},
0,
`{"created":"2026-08-18 10:20:30.456Z","data":{"a":"test1","b":"test2","c":"` + strings.Repeat("a", dataLimit-32) + `"},"id":"test_id","level":123,"message":"` + strings.Repeat("a", messageLimit) + `"}`,
},
{
"with message and data above the default limits",
core.Log{
BaseModel: core.BaseModel{Id: "test_id"},
Created: date,
Level: 123,
Message: strings.Repeat("a", messageLimit) + "x", // "x" should be omitted
Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", dataLimit-32) + "x"}, // the end will be incomplete and something like `"c":"...aaaaaax`
},
0,
`{"created":"2026-08-18 10:20:30.456Z","data":{"__pb_truncated__":true,"a":"test1","b":"test2","c":"` + strings.Repeat("a", dataLimit-32) + `x"},"id":"test_id","level":123,"message":"` + strings.Repeat("a", messageLimit) + `"}`,
},
{
"with data above custom limit",
core.Log{
BaseModel: core.BaseModel{Id: "test_id"},
Created: date,
Level: 123,
Message: "test_message",
Data: types.JSONMap[any]{"a": "test1", "b": "test2", "c": strings.Repeat("a", (2<<10)-32) + "x"}, // the end will be incomplete and something like `"c":"...aaaaaax`
},
2 << 10,
`{"created":"2026-08-18 10:20:30.456Z","data":{"__pb_truncated__":true,"a":"test1","b":"test2","c":"` + strings.Repeat("a", (2<<10)-32) + `x"},"id":"test_id","level":123,"message":"test_message"}`,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
testApp.Settings().Logs.MaxDataSize = s.limit
result, err := s.log.DBExport(testApp)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(result, json.Deterministic(true))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(raw, []byte(s.expectJSON)) {
t.Fatalf("Expected export data\n%s\ngot\n%s", s.expectJSON, raw)
}
})
}
}
+9 -2
View File
@@ -3,17 +3,24 @@ package core
import ( import (
"context" "context"
"database/sql" "database/sql"
"io"
"log/slog" "log/slog"
"os" "os"
"testing" "testing"
"time" "time"
"github.com/fatih/color"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/logger" "github.com/pocketbase/pocketbase/tools/logger"
) )
func TestBaseAppLoggerLevelDevPrint(t *testing.T) { func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
// temp unset to avoid littering the stdout if the test fails when in dev mode
colorOutput := color.Output
color.Output = io.Discard
defer func() { color.Output = colorOutput }()
testLogLevel := 4 testLogLevel := 4
scenarios := []struct { scenarios := []struct {
@@ -48,7 +55,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
DataDir: testDataDir, DataDir: testDataDir,
IsDev: s.isDev, IsDev: s.isDev,
}) })
defer app.ResetBootstrapState() defer app.ClearBootstrap()
if err := app.Bootstrap(); err != nil { if err := app.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -68,7 +75,7 @@ func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
var printedLevels []int var printedLevels []int
var persistedLevels []int var persistedLevels []int
ctx := context.Background() ctx := context.WithValue(context.Background(), logger.BlockKey, true)
// track printed logs // track printed logs
originalPrintLog := printLog originalPrintLog := printLog
+2 -2
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"testing" "testing"
"time" "time"
@@ -63,7 +63,7 @@ func TestLogsStats(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
encoded, _ := json.Marshal(result) encoded, _ := json.Marshal(result, json.Deterministic(true))
if string(encoded) != expected { if string(encoded) != expected {
t.Fatalf("Expected\n%q\ngot\n%q", expected, string(encoded)) t.Fatalf("Expected\n%q\ngot\n%q", expected, string(encoded))
} }
+1 -1
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"testing" "testing"
"time" "time"
+2 -2
View File
@@ -1,7 +1,7 @@
package core package core
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -354,7 +354,7 @@ func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (*search
// if that doesn't work, try encoding it // if that doesn't work, try encoding it
if castErr != nil { if castErr != nil {
encoded, jsonErr := json.Marshal(v) encoded, jsonErr := json.Marshal(v, json.Deterministic(true))
if jsonErr == nil { if jsonErr == nil {
val = string(encoded) val = string(encoded)
} }
+2 -2
View File
@@ -1,7 +1,7 @@
package core package core
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"reflect" "reflect"
@@ -333,7 +333,7 @@ func (r *runner) processRequestBodyEachModifier(bodyField Field) (*search.Resolv
} }
bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()]) bodyItems := toSlice(r.resolver.requestInfo.Body[bodyField.GetName()])
bodyItemsRaw, err := json.Marshal(bodyItems) bodyItemsRaw, err := json.Marshal(bodyItems, json.Deterministic(true))
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2]) return nil, fmt.Errorf("cannot serialize the data for field %q", r.activeProps[2])
} }
+2 -2
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -940,7 +940,7 @@ func TestRecordFieldResolverResolveStaticRequestInfoFields(t *testing.T) {
t.Fatalf("Expected parameter r.Identifier %q, got %q", paramName, r.Identifier) t.Fatalf("Expected parameter r.Identifier %q, got %q", paramName, r.Identifier)
} }
encodedParamValue, _ := json.Marshal(paramValue) encodedParamValue, _ := json.Marshal(paramValue, json.Deterministic(true))
if string(encodedParamValue) != s.expectParamValue { if string(encodedParamValue) != s.expectParamValue {
t.Fatalf("Expected r.Params %#v for %s, got %#v", s.expectParamValue, r.Identifier, string(encodedParamValue)) t.Fatalf("Expected r.Params %#v for %s, got %#v", s.expectParamValue, r.Identifier, string(encodedParamValue))
} }
+41 -16
View File
@@ -3,7 +3,9 @@ package core
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "database/sql"
"encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -959,6 +961,11 @@ func (m *Record) GetInt(key string) int {
return cast.ToInt(m.Get(key)) return cast.ToInt(m.Get(key))
} }
// GetInt64 returns the data value for "key" as an int64.
func (m *Record) GetInt64(key string) int64 {
return cast.ToInt64(m.Get(key))
}
// GetFloat returns the data value for "key" as a float64. // GetFloat returns the data value for "key" as a float64.
func (m *Record) GetFloat(key string) float64 { func (m *Record) GetFloat(key string) float64 {
return cast.ToFloat64(m.Get(key)) return cast.ToFloat64(m.Get(key))
@@ -1218,12 +1225,12 @@ func areValuesEqual(a any, b any) bool {
bv, ok := b.(types.JSONRaw) bv, ok := b.(types.JSONRaw)
return ok && bytes.Equal(av, bv) return ok && bytes.Equal(av, bv)
default: default:
aRaw, err := json.Marshal(a) aRaw, err := json.Marshal(a, json.Deterministic(true))
if err != nil { if err != nil {
return false return false
} }
bRaw, err := json.Marshal(b) bRaw, err := json.Marshal(b, json.Deterministic(true))
if err != nil { if err != nil {
return false return false
} }
@@ -1324,7 +1331,14 @@ func (record *Record) PublicExport() map[string]any {
// //
// Only the data exported by `PublicExport()` will be serialized. // Only the data exported by `PublicExport()` will be serialized.
func (m Record) MarshalJSON() ([]byte, error) { func (m Record) MarshalJSON() ([]byte, error) {
return json.Marshal(m.PublicExport()) return json.Marshal(
m.PublicExport(),
json.Deterministic(true),
// for compliance with old data (and slightly better performance)
jsontext.AllowDuplicateNames(true),
// preserve the old jsonv1 behavior in case of invalid data
jsontext.AllowInvalidUTF8(true),
)
} }
// UnmarshalJSON implements the [json.Unmarshaler] interface. // UnmarshalJSON implements the [json.Unmarshaler] interface.
@@ -1521,12 +1535,14 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
continue // skip missing or view collections continue // skip missing or view collections
} }
recordTableName := inflector.Columnify(refCollection.Name) refTableName := inflector.Columnify(refCollection.Name)
for _, field := range fields { for _, field := range fields {
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.GetName()) prefixedFieldName := refTableName + "." + inflector.Columnify(field.GetName())
query := app.RecordQuery(refCollection) // fetch only the related ids because they will be queried anyway right
// before delete to ensure that we are working with fresh record data
query := app.DB().Select(refTableName + ".id").From(refTableName)
if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() { if opt, ok := field.(MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id}) query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
@@ -1540,23 +1556,24 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
} }
if refCollection.Id == mainRecord.Collection().Id { if refCollection.Id == mainRecord.Collection().Id {
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id})) query.AndWhere(dbx.Not(dbx.HashExp{refTableName + ".id": mainRecord.Id}))
} }
// trigger cascade for each batchSize rel items until there is none // trigger cascade for each batchSize rel items until there is none
batchSize := 4000 batchSize := 8000
rows := make([]*Record, 0, batchSize) refIds := make([]string, 0, batchSize)
for { for {
if err := query.Limit(int64(batchSize)).All(&rows); err != nil { err := query.Limit(int64(batchSize)).Column(&refIds)
if err != nil {
return err return err
} }
total := len(rows) total := len(refIds)
if total == 0 { if total == 0 {
break break
} }
err := deleteRefRecords(app, mainRecord, rows, field) err = deleteRefRecords(app, mainRecord, refCollection, refIds, field)
if err != nil { if err != nil {
return err return err
} }
@@ -1565,7 +1582,7 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
break // no more items break // no more items
} }
rows = rows[:0] // keep allocated memory refIds = refIds[:0] // keep allocated memory
} }
} }
} }
@@ -1578,13 +1595,21 @@ func cascadeRecordDelete(app App, mainRecord *Record, refs map[*Collection][]Fie
// just unset the record id from any relation field values (if they are not required). // just unset the record id from any relation field values (if they are not required).
// //
// NB! This method is expected to be called from inside of a transaction. // NB! This method is expected to be called from inside of a transaction.
func deleteRefRecords(app App, mainRecord *Record, refRecords []*Record, field Field) error { func deleteRefRecords(app App, mainRecord *Record, refCollection *Collection, refIds []string, field Field) error {
relField, _ := field.(*RelationField) relField, _ := field.(*RelationField)
if relField == nil { if relField == nil {
return errors.New("only RelationField is supported at the moment, got " + field.Type()) return errors.New("only RelationField is supported at the moment, got " + field.Type())
} }
for _, refRecord := range refRecords { for _, refId := range refIds {
refRecord, err := app.FindRecordById(refCollection, refId)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue // already deleted
}
return err
}
ids := refRecord.GetStringSlice(relField.Name) ids := refRecord.GetStringSlice(relField.Name)
// unset the record id // unset the record id
+164 -28
View File
@@ -4,7 +4,8 @@ import (
"bytes" "bytes"
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
@@ -31,7 +32,7 @@ func TestNewRecord(t *testing.T) {
m := core.NewRecord(collection) m := core.NewRecord(collection)
rawData, err := json.Marshal(m.FieldsData()) // should be initialized with the defaults rawData, err := json.Marshal(m.FieldsData(), json.Deterministic(true)) // should be initialized with the defaults
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -520,7 +521,7 @@ func TestRecordMergeExpand(t *testing.T) {
result := m.Expand() result := m.Expand()
raw, err := json.Marshal(result) raw, err := json.Marshal(result, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -566,7 +567,7 @@ func TestRecordMergeExpandNilCheck(t *testing.T) {
m := core.NewRecord(collection) m := core.NewRecord(collection)
m.MergeExpand(s.expand) m.MergeExpand(s.expand)
raw, err := json.Marshal(m) raw, err := json.Marshal(m, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -663,7 +664,7 @@ func TestRecordFieldsData(t *testing.T) {
m.Set("field2", 456) m.Set("field2", 456)
m.Set("unknown", 789) m.Set("unknown", 789)
raw, err := json.Marshal(m.FieldsData()) raw, err := json.Marshal(m.FieldsData(), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -691,7 +692,7 @@ func TestRecordCustomData(t *testing.T) {
m.Set("field2", 456) m.Set("field2", 456)
m.Set("unknown", 789) m.Set("unknown", 789)
raw, err := json.Marshal(m.CustomData()) raw, err := json.Marshal(m.CustomData(), json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -899,6 +900,43 @@ func TestRecordGetInt(t *testing.T) {
} }
} }
func TestRecordGetInt64(t *testing.T) {
t.Parallel()
scenarios := []struct {
value any
expected int64
}{
{nil, 0},
{"", 0},
{[]string{"true"}, 0},
{map[string]int{"test": 1}, 0},
{time.Now(), 0},
{"test", 0},
{123, 123},
{2.4, 2},
{1<<63 - 1, 1<<63 - 1},
{"123", 123},
{"123.5", 123},
{false, 0},
{true, 1},
}
collection := core.NewBaseCollection("test")
record := core.NewRecord(collection)
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.value), func(t *testing.T) {
record.Set("test", s.value)
result := record.GetInt64("test")
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestRecordGetFloat(t *testing.T) { func TestRecordGetFloat(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1103,14 +1141,16 @@ func TestRecordGetUnsavedFiles(t *testing.T) {
t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) { t.Run(fmt.Sprintf("%d_%#v", i, s.key), func(t *testing.T) {
v := record.GetUnsavedFiles(s.key) v := record.GetUnsavedFiles(s.key)
raw, err := json.Marshal(v) raw, err := json.Marshal(v,
json.Deterministic(true),
json.FormatNilSliceAsNull(true),
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rawStr := string(raw)
if rawStr != s.expected { if str := string(raw); str != s.expected {
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr) t.Fatalf("Expected\n%s\ngot\n%s", s.expected, str)
} }
}) })
} }
@@ -1164,7 +1204,7 @@ func TestRecordUnmarshalJSONField(t *testing.T) {
t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr) t.Fatalf("Expected hasErr %v, got %v", s.expectError, hasErr)
} }
raw, _ := json.Marshal(s.destination) raw, _ := json.Marshal(s.destination, json.Deterministic(true))
if v := string(raw); v != s.expectedJSON { if v := string(raw); v != s.expectedJSON {
t.Fatalf("Expected %q, got %q", s.expectedJSON, v) t.Fatalf("Expected %q, got %q", s.expectedJSON, v)
} }
@@ -1271,7 +1311,7 @@ func TestRecordDBExport(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(result) raw, err := json.Marshal(result, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1373,14 +1413,15 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}} f3 := &core.SelectField{Name: "field3", MaxSelect: 2, Values: []string{"test1", "test2", "test3"}}
f4 := &core.TextField{Name: "field4", Hidden: true} f4 := &core.TextField{Name: "field4", Hidden: true}
f5 := &core.TextField{Name: "field5", Hidden: true} f5 := &core.TextField{Name: "field5", Hidden: true}
f6 := &core.JSONField{Name: "field6"}
colBase := core.NewBaseCollection("test_base") colBase := core.NewBaseCollection("test_base")
colBase.Id = "_pbc_base_123" colBase.Id = "_pbc_base_123"
colBase.Fields.Add(f1, f2, f3, f4, f5) colBase.Fields.Add(f1, f2, f3, f4, f5, f6)
colAuth := core.NewAuthCollection("test_auth") colAuth := core.NewAuthCollection("test_auth")
colAuth.Id = "_pbc_auth_123" colAuth.Id = "_pbc_auth_123"
colAuth.Fields.Add(f1, f2, f3, f4, f5) colAuth.Fields.Add(f1, f2, f3, f4, f5, f6)
scenarios := []struct { scenarios := []struct {
name string name string
@@ -1399,7 +1440,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false, false,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`, `{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
}, },
{ {
"[base] with email visibility", "[base] with email visibility",
@@ -1408,7 +1449,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false, false,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id"}`, `{"collectionId":"_pbc_base_123","collectionName":"test_base","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id"}`,
}, },
{ {
"[base] with custom data", "[base] with custom data",
@@ -1417,7 +1458,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`, `{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
}, },
{ {
"[base] with explicit hide and unhide fields", "[base] with explicit hide and unhide fields",
@@ -1426,7 +1467,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "tokenKey", "unknown"}, []string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "tokenKey", "unknown"},
[]string{"field4", "@pbInternalAbc"}, []string{"field4", "@pbInternalAbc"},
`{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","id":"test_id","password":"test_passwordHash","verified":true}`, `{"emailVisibility":"test_invalid","field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","verified":true}`,
}, },
{ {
"[base] trying to unhide custom fields without explicit WithCustomData", "[base] trying to unhide custom fields without explicit WithCustomData",
@@ -1435,7 +1476,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
nil, nil,
[]string{"field5", "@pbInternalAbc", "email", "tokenKey", "unknown"}, []string{"field5", "@pbInternalAbc", "email", "tokenKey", "unknown"},
`{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`, `{"collectionId":"_pbc_base_123","collectionName":"test_base","email":"test_email","emailVisibility":"test_invalid","expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","password":"test_passwordHash","tokenKey":"test_tokenKey","unknown":"test_unknown","verified":true}`,
}, },
// auth // auth
@@ -1446,7 +1487,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false, false,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`, `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
}, },
{ {
"[auth] with email visibility", "[auth] with email visibility",
@@ -1455,7 +1496,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
false, false,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","verified":true}`, `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","email":"test_email","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
}, },
{ {
"[auth] with custom data", "[auth] with custom data",
@@ -1464,7 +1505,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
nil, nil,
nil, nil,
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"id":"test_id","unknown":"test_unknown","verified":true}`, `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
}, },
{ {
"[auth] with explicit hide and unhide fields", "[auth] with explicit hide and unhide fields",
@@ -1473,7 +1514,7 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
[]string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "unknown"}, []string{"field3", "field1", "expand", "collectionId", "collectionName", "email", "unknown"},
[]string{"field4", "@pbInternalAbc"}, []string{"field4", "@pbInternalAbc"},
`{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","id":"test_id","verified":true}`, `{"emailVisibility":false,"field2":"field_2.png","field4":"field_4","field6":{"a":1,"a":2},"id":"test_id","verified":true}`,
}, },
{ {
"[auth] trying to unhide custom fields without explicit WithCustomData", "[auth] trying to unhide custom fields without explicit WithCustomData",
@@ -1482,17 +1523,18 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
true, true,
nil, nil,
[]string{"field5", "@pbInternalAbc", "tokenKey", "unknown", "email"}, // emailVisibility:false has higher priority []string{"field5", "@pbInternalAbc", "tokenKey", "unknown", "email"}, // emailVisibility:false has higher priority
`{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","id":"test_id","unknown":"test_unknown","verified":true}`, `{"collectionId":"_pbc_auth_123","collectionName":"test_auth","emailVisibility":false,"expand":{"test":123},"field1":"field_1�","field2":"field_2.png","field3":["test1","test2"],"field5":"field_5","field6":{"a":1,"a":2},"id":"test_id","unknown":"test_unknown","verified":true}`,
}, },
} }
data := map[string]any{ data := map[string]any{
"id": "test_id", "id": "test_id",
"field1": "field_1", "field1": "field_1\xc3", /* invalid utf8 suffix to test mangling */
"field2": "field_2.png", "field2": "field_2.png",
"field3": []string{"test1", "test2"}, "field3": []string{"test1", "test2"},
"field4": "field_4", "field4": "field_4",
"field5": "field_5", "field5": "field_5",
"field6": types.JSONRaw(`{"a":1,"a":2}`), // intentionally duplicated to check serialization
"expand": map[string]any{"test": 123}, "expand": map[string]any{"test": 123},
"collectionId": "m_id", // should be always ignored "collectionId": "m_id", // should be always ignored
"collectionName": "m_name", // should be always ignored "collectionName": "m_name", // should be always ignored
@@ -1515,7 +1557,12 @@ func TestRecordPublicExportAndMarshalJSON(t *testing.T) {
m.Unhide(s.unhideFields...) m.Unhide(s.unhideFields...)
m.Hide(s.hideFields...) m.Hide(s.hideFields...)
exportResult, err := json.Marshal(m.PublicExport()) exportResult, err := json.Marshal(
m.PublicExport(),
json.Deterministic(true),
jsontext.AllowDuplicateNames(true),
jsontext.AllowInvalidUTF8(true),
)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -2280,16 +2327,105 @@ func TestRecordDelete(t *testing.T) {
} }
// ensure that the json rel fields were prefixed // ensure that the json rel fields were prefixed
joinedQueries := strings.Join(calledQueries, " ") joinedQueries := strings.Join(calledQueries, " ")
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='" expectedRelManyPart := "SELECT `demo1`.`id` FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN iif(json_valid([[demo1.rel_many]]), json_type([[demo1.rel_many]])='array', FALSE) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
if !strings.Contains(joinedQueries, expectedRelManyPart) { if !strings.Contains(joinedQueries, expectedRelManyPart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries) t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
} }
expectedRelOnePart := "SELECT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`='" expectedRelOnePart := "SELECT `demo1`.`id` FROM `demo1` WHERE (`demo1`.`rel_one`='"
if !strings.Contains(joinedQueries, expectedRelOnePart) { if !strings.Contains(joinedQueries, expectedRelOnePart) {
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries) t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
} }
} }
func TestRecordDeleteWithMultipleRelationCascade(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
// create a mock collection with self referencing multiple relation field
// ---
collection := core.NewBaseCollection("test")
err := app.Save(collection)
if err != nil {
t.Fatal(err)
}
// for simpler mocks
collection.Fields.GetByName("id").(*core.TextField).Min = 1
collection.Fields.Add(&core.RelationField{
Name: "rels",
CollectionId: collection.Id,
MaxSelect: 99,
CascadeDelete: true,
})
err = app.Save(collection)
if err != nil {
t.Fatal(err)
}
// create mock records
// ---
relsData := map[string][]string{
"a": nil,
"b": {"a"},
"c": {"a", "b"},
"d": {},
"e": {"c", "d"},
}
for id, rels := range relsData {
record := core.NewRecord(collection)
record.Set("id", id)
record.Set("rels", rels)
err = app.SaveNoValidate(record) // map is not ordered
if err != nil {
t.Fatalf("failed to create mock record: %v", err)
}
}
// trigger cascade delete for the top record
// ---
aRecord, err := app.FindRecordById(collection, "a")
if err != nil {
t.Fatal(err)
}
err = app.Delete(aRecord)
if err != nil {
t.Fatal(err)
}
// verify cascade delete
// ---
expectedRels := map[string][]string{
"d": {},
"e": {"d"},
}
allRecords, err := app.FindAllRecords(collection)
if err != nil {
t.Fatal(err)
}
if len(allRecords) != len(expectedRels) {
t.Fatalf("Expected %d remaining records, got %d", len(expectedRels), len(allRecords))
}
for _, r := range allRecords {
expected, ok := expectedRels[r.Id]
if !ok {
t.Fatalf("Record %q wasn't found in %v", r.Id, expectedRels)
}
rels := r.GetStringSlice("rels")
if !slices.Equal(rels, expected) {
t.Fatalf("Record %q expected rels\n%v\ngot\n%v", r.Id, expected, rels)
}
}
}
func TestRecordDeleteBatchProcessing(t *testing.T) { func TestRecordDeleteBatchProcessing(t *testing.T) {
t.Parallel() t.Parallel()
+3 -3
View File
@@ -104,7 +104,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
return nil return nil
default: // expects []RecordProxy slice default: // expects []RecordProxy slice
rv := reflect.ValueOf(v) rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() { if rv.Kind() != reflect.Pointer || rv.IsNil() {
return errors.New("must be a pointer") return errors.New("must be a pointer")
} }
@@ -117,7 +117,7 @@ func (app *BaseApp) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuer
et := rv.Type().Elem() et := rv.Type().Elem()
var isSliceOfPointers bool var isSliceOfPointers bool
if et.Kind() == reflect.Ptr { if et.Kind() == reflect.Pointer {
isSliceOfPointers = true isSliceOfPointers = true
et = et.Elem() et = et.Elem()
} }
@@ -182,7 +182,7 @@ func resolveRecordAllHook(collection *Collection, op func(dst any) error) ([]*Re
// dereference returns the underlying value v points to. // dereference returns the underlying value v points to.
func dereference(v reflect.Value) reflect.Value { func dereference(v reflect.Value) reflect.Value {
for v.Kind() == reflect.Ptr { for v.Kind() == reflect.Pointer {
if v.IsNil() { if v.IsNil() {
// initialize with a new value and continue searching // initialize with a new value and continue searching
v.Set(reflect.New(v.Type().Elem())) v.Set(reflect.New(v.Type().Elem()))
+3 -3
View File
@@ -3,7 +3,7 @@ package core_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"strings" "strings"
"testing" "testing"
@@ -240,7 +240,7 @@ func TestExpandRecords(t *testing.T) {
t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed) t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed)
} }
encoded, _ := json.Marshal(records) encoded, _ := json.Marshal(records, json.Deterministic(true))
encodedStr := string(encoded) encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`) totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`)
totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`) totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`)
@@ -420,7 +420,7 @@ func TestExpandRecord(t *testing.T) {
t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed) t.Errorf("Expected %d failures, got %d\n%v", s.expectExpandFailures, len(failed), failed)
} }
encoded, _ := json.Marshal(record) encoded, _ := json.Marshal(record, json.Deterministic(true))
encodedStr := string(encoded) encodedStr := string(encoded)
totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`) totalExpandProps := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":`)
totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`) totalEmptyExpands := strings.Count(encodedStr, `"`+core.FieldNameExpand+`":{}`)
+3 -3
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"slices" "slices"
@@ -107,7 +107,7 @@ func TestRecordQueryOne(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(s.model) raw, err := json.Marshal(s.model, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -192,7 +192,7 @@ func TestRecordQueryAll(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, err := json.Marshal(s.result) raw, err := json.Marshal(s.result, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+13 -45
View File
@@ -2,7 +2,7 @@ package core
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -237,7 +237,7 @@ func (s *Settings) String() string {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
raw, _ := json.Marshal(s) raw, _ := s.MarshalJSON()
return string(raw) return string(raw)
} }
@@ -257,14 +257,7 @@ func (s *Settings) DBExport(app App) (map[string]any, error) {
} }
result["updated"] = now result["updated"] = now
// @todo remove with encoding/json/2 encoded, err := json.Marshal(s.settings, json.Deterministic(true))
// serialize as empty array
//nolint:staticcheck
if s.settings.SuperuserIPs == nil {
s.settings.SuperuserIPs = []string{}
}
encoded, err := json.Marshal(s.settings)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -355,13 +348,7 @@ func (s *Settings) MarshalJSON() ([]byte, error) {
} }
} }
// @todo remove with encoding/json/2 return json.Marshal(copy, json.Deterministic(true))
// serialize as empty array
if copy.SuperuserIPs == nil {
copy.SuperuserIPs = []string{}
}
return json.Marshal(copy)
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -559,6 +546,12 @@ func (c MetaConfig) Validate() error {
// ------------------------------------------------------------------- // -------------------------------------------------------------------
type LogsConfig struct { type LogsConfig struct {
// MaxDataSize specifies the maximum allowed serialized log data
// size before it gets truncated (see [Log.DBExport]).
//
// If zero, fallbacks to ~16kb by default.
MaxDataSize int64 `form:"maxDataSize" json:"maxDataSize"`
MaxDays int `form:"maxDays" json:"maxDays"` MaxDays int `form:"maxDays" json:"maxDays"`
MinLevel int `form:"minLevel" json:"minLevel"` MinLevel int `form:"minLevel" json:"minLevel"`
LogIP bool `form:"logIP" json:"logIP"` LogIP bool `form:"logIP" json:"logIP"`
@@ -568,7 +561,9 @@ type LogsConfig struct {
// Validate makes LogsConfig validatable by implementing [validation.Validatable] interface. // Validate makes LogsConfig validatable by implementing [validation.Validatable] interface.
func (c LogsConfig) Validate() error { func (c LogsConfig) Validate() error {
return validation.ValidateStruct(&c, return validation.ValidateStruct(&c,
validation.Field(&c.MaxDays, validation.Min(0)), validation.Field(&c.MaxDataSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&c.MaxDays, validation.Min(0), validation.Max(maxSafeJSONInt)),
validation.Field(&c.MinLevel, validation.Max(maxSafeJSONInt)),
) )
} }
@@ -586,18 +581,6 @@ type TrustedProxyConfig struct {
UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"` UseLeftmostIP bool `form:"useLeftmostIP" json:"useLeftmostIP"`
} }
// MarshalJSON implements the [json.Marshaler] interface.
func (c TrustedProxyConfig) MarshalJSON() ([]byte, error) {
type alias TrustedProxyConfig
// serialize as empty array
if c.Headers == nil {
c.Headers = []string{}
}
return json.Marshal(alias(c))
}
// Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface. // Validate makes RateLimitRule validatable by implementing [validation.Validatable] interface.
func (c TrustedProxyConfig) Validate() error { func (c TrustedProxyConfig) Validate() error {
return nil return nil
@@ -645,21 +628,6 @@ func (c *RateLimitsConfig) FindRateLimitRule(searchLabels []string, optOnlyAudie
return RateLimitRule{}, false return RateLimitRule{}, false
} }
// MarshalJSON implements the [json.Marshaler] interface.
func (c RateLimitsConfig) MarshalJSON() ([]byte, error) {
type alias RateLimitsConfig
// serialize as empty array
if c.Rules == nil {
c.Rules = []RateLimitRule{}
}
if c.ExcludedIPs == nil {
c.ExcludedIPs = []string{}
}
return json.Marshal(alias(c))
}
// Validate makes RateLimitsConfig validatable by implementing [validation.Validatable] interface. // Validate makes RateLimitsConfig validatable by implementing [validation.Validatable] interface.
func (c RateLimitsConfig) Validate() error { func (c RateLimitsConfig) Validate() error {
return validation.ValidateStruct(&c, return validation.ValidateStruct(&c,
+14 -11
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -84,7 +84,7 @@ func TestSettings_DBExport(t *testing.T) {
valueStr = string(export["value"].([]byte)) valueStr = string(export["value"].([]byte))
} }
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}` expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","password":"","authMethod":"","tls":false,"localName":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"accentColor":"","appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDataSize":0,"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}`
if valueStr != expected { if valueStr != expected {
t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr) t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr)
} }
@@ -112,12 +112,12 @@ func TestSettingsMerge(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
s1Encoded, err := json.Marshal(s1) s1Encoded, err := json.Marshal(s1, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
s2Encoded, err := json.Marshal(s2) s2Encoded, err := json.Marshal(s2, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -138,12 +138,12 @@ func TestSettingsClone(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
s1Bytes, err := json.Marshal(s1) s1Bytes, err := json.Marshal(s1, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
s2Bytes, err := json.Marshal(s2) s2Bytes, err := json.Marshal(s2, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -174,13 +174,13 @@ func TestSettingsMarshalJSON(t *testing.T) {
settings.S3.Secret = testSecret settings.S3.Secret = testSecret
settings.Backups.S3.Secret = testSecret settings.Backups.S3.Secret = testSecret
raw, err := json.Marshal(settings) raw, err := json.Marshal(settings, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rawStr := string(raw) rawStr := string(raw)
expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}` expected := `{"superuserIPs":[],"smtp":{"enabled":false,"port":0,"host":"","username":"abc","authMethod":"","tls":false,"localName":""},"backups":{"cron":"","cronMaxKeep":0,"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false},"meta":{"accentColor":"","appName":"test123","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"excludedIPs":[],"enabled":false},"trustedProxy":{"headers":[],"useLeftmostIP":false},"batch":{"enabled":false,"maxRequests":0,"timeout":0,"maxBodySize":0},"logs":{"maxDataSize":0,"maxDays":0,"minLevel":0,"logIP":false,"logAuthId":false}}`
if rawStr != expected { if rawStr != expected {
t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr) t.Fatalf("Expected\n%v\ngot\n%v", expected, rawStr)
@@ -228,7 +228,7 @@ func TestSettingsValidate(t *testing.T) {
`"rateLimits":{`, `"rateLimits":{`,
} }
errBytes, _ := json.Marshal(err) errBytes, _ := json.Marshal(err, json.Deterministic(true))
jsonErr := string(errBytes) jsonErr := string(errBytes)
for _, expected := range expectations { for _, expected := range expectations {
if !strings.Contains(jsonErr, expected) { if !strings.Contains(jsonErr, expected) {
@@ -309,8 +309,11 @@ func TestLogsConfigValidate(t *testing.T) {
}, },
{ {
"invalid data", "invalid data",
core.LogsConfig{MaxDays: -1}, core.LogsConfig{
[]string{"maxDays"}, MaxDays: -1,
MaxDataSize: -1,
},
[]string{"maxDays", "maxDataSize"},
}, },
{ {
"valid data", "valid data",
+1 -1
View File
@@ -2,7 +2,7 @@ package core
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"os" "os"
+2 -2
View File
@@ -19,7 +19,7 @@ func TestSendSystemAlert(t *testing.T) {
testApp := NewBaseApp(BaseAppConfig{ testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer testApp.ResetBootstrapState() defer testApp.ClearBootstrap()
if err := testApp.Bootstrap(); err != nil { if err := testApp.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -72,7 +72,7 @@ func TestSendSystemAlertToAllSuperusers(t *testing.T) {
testApp := NewBaseApp(BaseAppConfig{ testApp := NewBaseApp(BaseAppConfig{
DataDir: testDataDir, DataDir: testDataDir,
}) })
defer testApp.ResetBootstrapState() defer testApp.ClearBootstrap()
if err := testApp.Bootstrap(); err != nil { if err := testApp.Bootstrap(); err != nil {
t.Fatal(err) t.Fatal(err)
+3 -3
View File
@@ -1,7 +1,7 @@
package core_test package core_test
import ( import (
"encoding/json" "encoding/json/v2"
"fmt" "fmt"
"slices" "slices"
"testing" "testing"
@@ -530,7 +530,7 @@ func TestCreateViewFields(t *testing.T) {
} }
if len(s.expectFields) != len(result) { if len(s.expectFields) != len(result) {
serialized, _ := json.Marshal(result) serialized, _ := json.Marshal(result, json.Deterministic(true))
t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result), serialized) t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result), serialized)
} }
@@ -835,7 +835,7 @@ func TestDryRunView(t *testing.T) {
// check fields // check fields
// --- // ---
if len(s.expectFields) != len(result.Fields) { if len(s.expectFields) != len(result.Fields) {
serialized, _ := json.Marshal(result.Fields) serialized, _ := json.Marshal(result.Fields, json.Deterministic(true))
t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized) t.Fatalf("Expected %d fields, got %d: \n%s", len(s.expectFields), len(result.Fields), serialized)
} }
for name, typ := range s.expectFields { for name, typ := range s.expectFields {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json/v2"
"encoding/pem" "encoding/pem"
"testing" "testing"
+1 -1
View File
@@ -2,7 +2,7 @@ package forms_test
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"maps" "maps"
"os" "os"
+19 -18
View File
@@ -1,15 +1,15 @@
module github.com/pocketbase/pocketbase module github.com/pocketbase/pocketbase
go 1.25.0 go 1.27
require ( require (
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/domodwyer/mailyak/v3 v3.6.2 github.com/domodwyer/mailyak/v3 v3.6.2
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 github.com/dop251/goja v0.0.0-20260901132549-43234fa61381
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/gabriel-vasile/mimetype v1.4.13 github.com/gabriel-vasile/mimetype v1.4.15
github.com/ganigeorgiev/fexpr v0.6.0 github.com/ganigeorgiev/fexpr v0.6.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
@@ -17,33 +17,34 @@ require (
github.com/pocketbase/tygoja v0.1.0 github.com/pocketbase/tygoja v0.1.0
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.45.0 golang.org/x/image v0.46.0
golang.org/x/net v0.58.0 golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.22.0 golang.org/x/sync v0.23.0
modernc.org/sqlite v1.55.0 modernc.org/sqlite v1.60.0
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/mod v0.40.0 // indirect github.com/stretchr/testify v1.8.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/mod v0.41.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/tools v0.49.0 // indirect golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.74.1 // indirect golang.org/x/tools v0.50.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.12.1 // indirect
) )
+49 -43
View File
@@ -4,18 +4,19 @@ github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:o
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY= github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM= github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 h1:1JJPIzrFPTNEHCFkIDhKV2CHBklTA/7VHJp9sVB8Em0= github.com/dop251/goja v0.0.0-20260901132549-43234fa61381 h1:vzmJ+R9qxuaksCAB+Kn6wtGgQ01cIfKzUGwWWcX5zXI=
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg= github.com/dop251/goja v0.0.0-20260901132549-43234fa61381/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM= github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -26,8 +27,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus= github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
@@ -41,8 +42,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -55,8 +56,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -80,63 +81,68 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= modernc.org/sqlite v1.60.0 h1:7AZh8lREDo8x3j7aSdF7KGpAKUkJExJ1p67tcRnmttM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/sqlite v1.60.0/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+6 -1
View File
@@ -7,7 +7,7 @@ linters:
enable: enable:
- asasalint - asasalint
- asciicheck - asciicheck
- gomodguard - gomodguard_v2
- goprintffuncname - goprintffuncname
- govet - govet
- ineffassign - ineffassign
@@ -20,6 +20,11 @@ linters:
- unconvert - unconvert
- unused - unused
- whitespace - whitespace
exclusions:
rules:
- path: _test\.go
linters:
- prealloc
formatters: formatters:
enable: enable:
- gofmt - gofmt
+1 -1
View File
@@ -64,7 +64,7 @@ const Layout = `
text-align: center; text-align: center;
padding: 0 20px; padding: 0 20px;
margin: 5px 0; margin: 5px 0;
font-family: Source Sans Pro, sans-serif, emoji;; font-family: Source Sans Pro, sans-serif, emoji;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
border-radius: 6px; border-radius: 6px;
+1 -1
View File
@@ -1,7 +1,7 @@
package migrations package migrations
import ( import (
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"os" "os"
+2 -2
View File
@@ -10,8 +10,8 @@ import (
) )
const ( const (
expectedDriverVersion = "v1.55.0" expectedDriverVersion = "v1.60.0"
expectedLibcVersion = "v1.74.1" expectedLibcVersion = "v1.77.1"
// ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks. // ModerncDepsCheckHookId is the id of the hook that performs the modernc.org/* deps checks.
// It could be used for removing/unbinding the hook if you don't want the checks. // It could be used for removing/unbinding the hook if you don't want the checks.
+1 -1
View File
@@ -8,7 +8,7 @@ package ghupdate
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
+5 -5
View File
@@ -3,7 +3,7 @@ package jsvm
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"io" "io"
"io/fs" "io/fs"
@@ -352,7 +352,7 @@ func BindCore(vm *goja.Runtime) {
} }
// as a last attempt try to json encode the value // as a last attempt try to json encode the value
rawBytes, _ := json.Marshal(raw) rawBytes, _ := json.Marshal(raw, json.Deterministic(true))
return rawBytes, nil return rawBytes, nil
} }
@@ -381,7 +381,7 @@ func BindCore(vm *goja.Runtime) {
} }
// as a last attempt try to json encode the value // as a last attempt try to json encode the value
rawBytes, _ := json.Marshal(raw) rawBytes, _ := json.Marshal(raw, json.Deterministic(true))
return string(rawBytes), nil return string(rawBytes), nil
} }
@@ -1217,13 +1217,13 @@ func newDynamicModel(shape map[string]any) any {
case reflect.Map: case reflect.Map:
raw, _ := json.Marshal(v) raw, _ := json.Marshal(v)
newV := types.JSONMap[any]{} newV := types.JSONMap[any]{}
newV.Scan(raw) _ = newV.Scan(raw)
v = newV v = newV
vt = reflect.TypeOf(v) vt = reflect.TypeOf(v)
case reflect.Slice, reflect.Array: case reflect.Slice, reflect.Array:
raw, _ := json.Marshal(v) raw, _ := json.Marshal(v)
newV := types.JSONArray[any]{} newV := types.JSONArray[any]{}
newV.Scan(raw) _ = newV.Scan(raw)
v = newV v = newV
vt = reflect.TypeOf(newV) vt = reflect.TypeOf(newV)
case reflect.Pointer: case reflect.Pointer:
+6 -6
View File
@@ -2,7 +2,7 @@ package jsvm
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -505,12 +505,12 @@ func TestBindCoreMailerMessage(t *testing.T) {
t.Fatalf("Expected mailer.Message, got %v", m) t.Fatalf("Expected mailer.Message, got %v", m)
} }
raw, err := json.Marshal(m) raw, err := json.Marshal(m, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":null,"inlineAttachments":null}` expected := `{"from":{"Name":"test_from","Address":"test_from@example.com"},"to":[{"Name":"test_to1","Address":"test_to1@example.com"},{"Name":"test_to2","Address":"test_to2@example.com"}],"bcc":[{"Name":"test_bcc1","Address":"test_bcc1@example.com"},{"Name":"test_bcc2","Address":"test_bcc2@example.com"}],"cc":[{"Name":"test_cc1","Address":"test_cc1@example.com"},{"Name":"test_cc2","Address":"test_cc2@example.com"}],"subject":"test_subject","html":"test_html","text":"test_text","headers":{"header1":"a","header2":"b"},"attachments":{},"inlineAttachments":{}}`
if string(raw) != expected { if string(raw) != expected {
t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw) t.Fatalf("Expected \n%s, \ngot \n%s", expected, raw)
@@ -1178,7 +1178,7 @@ func TestBindApisErrors(t *testing.T) {
t.Errorf("[%s] Expected Message %q, got %q", s.js, s.expectMessage, apiErr.Message) t.Errorf("[%s] Expected Message %q, got %q", s.js, s.expectMessage, apiErr.Message)
} }
dataRaw, _ := json.Marshal(apiErr.RawData()) dataRaw, _ := json.Marshal(apiErr.RawData(), json.Deterministic(true))
if string(dataRaw) != s.expectData { if string(dataRaw) != s.expectData {
t.Errorf("[%s] Expected Data %q, got %q", s.js, s.expectData, dataRaw) t.Errorf("[%s] Expected Data %q, got %q", s.js, s.expectData, dataRaw)
} }
@@ -1439,7 +1439,7 @@ func TestBindHTTPSend(t *testing.T) {
res.Header().Add("X-Custom", "custom_header") res.Header().Add("X-Custom", "custom_header")
res.Header().Add("Set-Cookie", "sessionId=123456") res.Header().Add("Set-Cookie", "sessionId=123456")
infoRaw, _ := json.Marshal(info) infoRaw, _ := json.Marshal(info, json.Deterministic(true))
// write back the submitted request // write back the submitted request
res.Write(infoRaw) res.Write(infoRaw)
@@ -1613,7 +1613,7 @@ func TestHooksBindsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
hooksBinds(app, vm, nil) hooksBinds(app, vm, nil)
testBindsCount(vm, "this", 82, t) testBindsCount(vm, "this", 83, t)
} }
func TestHooksBinds(t *testing.T) { func TestHooksBinds(t *testing.T) {
+2 -2
View File
@@ -2,7 +2,7 @@ package jsvm
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/v2"
"strings" "strings"
"testing" "testing"
@@ -166,7 +166,7 @@ func TestFormDataEntries(t *testing.T) {
entries := data.Entries() entries := data.Entries()
rawEntries, err := json.Marshal(entries) rawEntries, err := json.Marshal(entries, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -129,7 +129,7 @@ type excludeHooks<Type> = {
type CoreApp = excludeHooks<ORIGINAL_CORE_APP> type CoreApp = excludeHooks<ORIGINAL_CORE_APP>
// pocketbase.PocketBase without the on* hook methods // pocketbase.PocketBase without the on* hook methods
type PocketBase = excludeHooks<ORIGINAL_POCKETBASE> interface PocketBase extends excludeHooks<ORIGINAL_POCKETBASE>{}
/** /**
* ` + "`$app`" + ` is the current running PocketBase instance that is globally * ` + "`$app`" + ` is the current running PocketBase instance that is globally
+24 -4
View File
@@ -83,10 +83,10 @@ type Config struct {
HooksFilesPattern string HooksFilesPattern string
// HooksPoolSize specifies how many goja.Runtime instances to prewarm // HooksPoolSize specifies how many goja.Runtime instances to prewarm
// and keep for the JS app hooks gorotines execution. // and keep for the JS app hooks goroutine execution to reuse.
// //
// Zero or negative value means that it will create a new goja.Runtime // Zero or negative value means that no pool will be maintained and
// on every fired goroutine. // instead it will create a new goja.Runtime on every fired goroutine.
HooksPoolSize int HooksPoolSize int
// MigrationsDir specifies the JS migrations directory. // MigrationsDir specifies the JS migrations directory.
@@ -218,7 +218,27 @@ func (p *plugin) registerMigrations() error {
vm.Set("__hooks", absHooksDir) vm.Set("__hooks", absHooksDir)
vm.Set("migrate", func(up, down func(txApp core.App) error) { vm.Set("migrate", func(up, down func(txApp core.App) error) {
core.AppMigrations.Register(up, down, file) // note: safe wrap to capture any eventual panic and to allow
// the error message to print the migration filename
core.AppMigrations.Register(
func(txApp core.App) error {
return routine.SafeWrap(func() error {
if up == nil {
return nil
}
return up(txApp)
})()
},
func(txApp core.App) error {
return routine.SafeWrap(func() error {
if down == nil {
return nil
}
return down(txApp)
})()
},
file,
)
}) })
if p.config.OnInit != nil { if p.config.OnInit != nil {
+4
View File
@@ -20,6 +20,10 @@ type vmsPool struct {
// newPool creates a new pool with pre-warmed vms generated from the specified factory. // newPool creates a new pool with pre-warmed vms generated from the specified factory.
func newPool(size int, factory func() *goja.Runtime) *vmsPool { func newPool(size int, factory func() *goja.Runtime) *vmsPool {
if size < 0 {
size = 0
}
pool := &vmsPool{ pool := &vmsPool{
factory: factory, factory: factory,
items: make([]*poolItem, size), items: make([]*poolItem, size),
+2 -1
View File
@@ -194,7 +194,8 @@ func (p *plugin) migrateCreateHandler(template string, args []string, interactiv
} }
func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (string, error) { func (p *plugin) migrateCollectionsHandler(args []string, interactive bool) (string, error) {
createArgs := []string{"collections_snapshot"} createArgs := make([]string, 0, len(args)+1)
createArgs = append(createArgs, "collections_snapshot")
createArgs = append(createArgs, args...) createArgs = append(createArgs, args...)
collections := []*core.Collection{} collections := []*core.Collection{}
+11 -11
View File
@@ -93,12 +93,12 @@ migrate((app) => {
"type": "text" "type": "text"
}, },
{ {
"exceptDomains": null, "exceptDomains": [],
"help": "", "help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
"onlyDomains": null, "onlyDomains": [],
"presentable": false, "presentable": false,
"required": true, "required": true,
"system": true, "system": true,
@@ -200,7 +200,7 @@ migrate((app) => {
package _test_migrations package _test_migrations
import ( import (
"encoding/json" "encoding/json/v2"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
@@ -275,12 +275,12 @@ func init() {
"type": "text" "type": "text"
}, },
{ {
"exceptDomains": null, "exceptDomains": [],
"help": "", "help": "",
"hidden": false, "hidden": false,
"id": "email@TEST_RANDOM", "id": "email@TEST_RANDOM",
"name": "email", "name": "email",
"onlyDomains": null, "onlyDomains": [],
"presentable": false, "presentable": false,
"required": true, "required": true,
"system": true, "system": true,
@@ -546,12 +546,12 @@ migrate((app) => {
"type": "text" "type": "text"
}, },
{ {
"exceptDomains": null, "exceptDomains": [],
"help": "", "help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
"onlyDomains": null, "onlyDomains": [],
"presentable": false, "presentable": false,
"required": true, "required": true,
"system": true, "system": true,
@@ -649,7 +649,7 @@ migrate((app) => {
package _test_migrations package _test_migrations
import ( import (
"encoding/json" "encoding/json/v2"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
@@ -731,12 +731,12 @@ func init() {
"type": "text" "type": "text"
}, },
{ {
"exceptDomains": null, "exceptDomains": [],
"help": "", "help": "",
"hidden": false, "hidden": false,
"id": "email3885137012", "id": "email3885137012",
"name": "email", "name": "email",
"onlyDomains": null, "onlyDomains": [],
"presentable": false, "presentable": false,
"required": true, "required": true,
"system": true, "system": true,
@@ -1041,7 +1041,7 @@ migrate((app) => {
package _test_migrations package _test_migrations
import ( import (
"encoding/json" "encoding/json/v2"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
+12 -19
View File
@@ -2,12 +2,12 @@ package migratecmd
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json/jsontext"
"encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv"
"strings" "strings"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
@@ -383,7 +383,7 @@ func (p *plugin) goCreateTemplate(collection *core.Collection) (string, error) {
const template = `package %s const template = `package %s
import ( import (
"encoding/json" "encoding/json/v2"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
@@ -436,7 +436,7 @@ func (p *plugin) goDeleteTemplate(collection *core.Collection) (string, error) {
const template = `package %s const template = `package %s
import ( import (
"encoding/json" "encoding/json/v2"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations" m "github.com/pocketbase/pocketbase/migrations"
@@ -621,7 +621,7 @@ func (p *plugin) goDiffTemplate(new *core.Collection, old *core.Collection) (str
if strings.Contains(combined, "json.Unmarshal(") || if strings.Contains(combined, "json.Unmarshal(") ||
strings.Contains(combined, "json.Marshal(") { strings.Contains(combined, "json.Marshal(") {
imports += "\n\t\"encoding/json\"\n" imports += "\n\t\"encoding/json/v2\"\n"
} }
imports += "\n\t\"github.com/pocketbase/pocketbase/core\"" imports += "\n\t\"github.com/pocketbase/pocketbase/core\""
@@ -666,18 +666,11 @@ func init() {
} }
func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) { func marhshalWithoutEscape(v any, prefix string, indent string) ([]byte, error) {
raw, err := json.MarshalIndent(v, prefix, indent) return json.Marshal(v,
if err != nil { jsontext.WithIndentPrefix(prefix),
return nil, err jsontext.WithIndent(indent),
} json.Deterministic(true),
)
// unescape escaped unicode characters
unescaped, err := strconv.Unquote(strings.ReplaceAll(strconv.Quote(string(raw)), `\\u`, `\u`))
if err != nil {
return nil, err
}
return []byte(unescaped), nil
} }
func escapeBacktick(v string) string { func escapeBacktick(v string) string {
@@ -720,8 +713,8 @@ func diffMaps(old, new map[string]any, excludeKeys ...string) map[string]any {
} }
// compare the serialized version of the values in case of slice or other custom type // compare the serialized version of the values in case of slice or other custom type
rawOld, _ := json.Marshal(vOld) rawOld, _ := json.Marshal(vOld, json.Deterministic(true))
rawNew, _ := json.Marshal(vNew) rawNew, _ := json.Marshal(vNew, json.Deterministic(true))
if !bytes.Equal(rawOld, rawNew) { if !bytes.Equal(rawOld, rawNew) {
// if both are maps add recursively only the changed fields // if both are maps add recursively only the changed fields
+21 -35
View File
@@ -1,6 +1,7 @@
package pocketbase package pocketbase
import ( import (
"errors"
"io" "io"
"os" "os"
"os/signal" "os/signal"
@@ -9,7 +10,6 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/fatih/color"
"github.com/pocketbase/pocketbase/cmd" "github.com/pocketbase/pocketbase/cmd"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
@@ -117,8 +117,9 @@ func NewWithConfig(config Config) *PocketBase {
hideStartBanner: config.HideStartBanner, hideStartBanner: config.HideStartBanner,
} }
// replace with a colored stderr writer // don't write command errors to the stderr because the error is
pb.RootCmd.SetErr(newErrWriter()) // propagated back to the app.Start() and could result in duplication
pb.RootCmd.SetErr(&nopWrite{})
// parse base flags // parse base flags
// (errors are ignored, since the full flags parsing happens on Execute()) // (errors are ignored, since the full flags parsing happens on Execute())
@@ -183,26 +184,25 @@ func (pb *PocketBase) Execute() error {
} }
} }
done := make(chan bool, 1) execCh := make(chan error, 1)
sigCh := make(chan os.Signal, 1)
// listen for interrupt signal to gracefully shutdown the application // listen for interrupt signal to gracefully shutdown the application
go func() { signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sigch := make(chan os.Signal, 1)
signal.Notify(sigch, os.Interrupt, syscall.SIGTERM)
<-sigch
done <- true
}()
// execute the root command // execute the root command
go func() { go func() {
// note: leave to the commands to decide whether to print their error execCh <- routine.SafeWrap(pb.RootCmd.Execute)()
pb.RootCmd.Execute()
done <- true
}() }()
<-done // wait for either an OS signal or the command to complete
var execErr error
select {
case <-sigCh:
case execErr = <-execCh:
}
signal.Stop(sigCh)
// trigger cleanups // trigger cleanups
// //
@@ -210,7 +210,7 @@ func (pb *PocketBase) Execute() error {
event := new(core.TerminateEvent) event := new(core.TerminateEvent)
event.App = pb event.App = pb
return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error { return pb.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
return e.App.ResetBootstrapState() return errors.Join(e.App.ClearBootstrap(), execErr)
}) })
} }
@@ -308,24 +308,10 @@ func inspectRuntime() (baseDir string, withGoRun bool) {
return return
} }
// newErrWriter returns a red colored stderr writter. var _ io.Writer = (*nopWrite)(nil)
func newErrWriter() *coloredWriter {
return &coloredWriter{
w: os.Stderr,
c: color.New(color.FgRed),
}
}
// coloredWriter is a small wrapper struct to construct a [color.Color] writter. type nopWrite struct{}
type coloredWriter struct {
w io.Writer
c *color.Color
}
// Write writes the p bytes using the colored writer. func (w *nopWrite) Write(p []byte) (n int, err error) {
func (colored *coloredWriter) Write(p []byte) (n int, err error) { return
colored.c.SetWriter(colored.w)
defer colored.c.UnsetWriter(colored.w)
return colored.c.Print(string(p))
} }
+7 -4
View File
@@ -3,7 +3,7 @@ package tests
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json/jsontext"
"fmt" "fmt"
"io" "io"
"maps" "maps"
@@ -249,6 +249,7 @@ func (scenario *ApiScenario) test(t testing.TB) {
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode) t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
} }
// @todo consider removing in favour of synctest.Wait()
if scenario.Delay > 0 { if scenario.Delay > 0 {
time.Sleep(scenario.Delay) time.Sleep(scenario.Delay)
} }
@@ -259,14 +260,16 @@ func (scenario *ApiScenario) test(t testing.TB) {
} }
} else { } else {
// normalize json response format // normalize json response format
buffer := new(bytes.Buffer)
err := json.Compact(buffer, recorder.Body.Bytes())
var normalizedBody string var normalizedBody string
buf := new(bytes.Buffer)
enc := jsontext.NewEncoder(buf)
err := enc.WriteValue(recorder.Body.Bytes())
if err != nil { if err != nil {
// not a json... // not a json...
normalizedBody = recorder.Body.String() normalizedBody = recorder.Body.String()
} else { } else {
normalizedBody = buffer.String() normalizedBody = buf.String()
} }
for _, item := range scenario.ExpectedContent { for _, item := range scenario.ExpectedContent {
+24 -1
View File
@@ -37,9 +37,9 @@ func (t *TestApp) Cleanup() {
event.App = t event.App = t
t.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error { t.OnTerminate().Trigger(event, func(e *core.TerminateEvent) error {
t.ClearBootstrap()
t.TestMailer.Reset() t.TestMailer.Reset()
t.ResetEventCalls() t.ResetEventCalls()
t.ResetBootstrapState()
return e.Next() return e.Next()
}) })
@@ -144,6 +144,14 @@ func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
Priority: -99999, Priority: -99999,
}) })
t.OnBootstrapClear().Bind(&hook.Handler[*core.BootstrapEvent]{
Func: func(e *core.BootstrapEvent) error {
t.registerEventCall("OnBootstrapClear")
return e.Next()
},
Priority: -99999,
})
t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{ t.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(e *core.ServeEvent) error { Func: func(e *core.ServeEvent) error {
t.registerEventCall("OnServe") t.registerEventCall("OnServe")
@@ -549,6 +557,21 @@ func NewTestAppWithConfig(config core.BaseAppConfig) (*TestApp, error) {
Priority: -99999, Priority: -99999,
}) })
// t.OnFilesystemDelete().Bind(&hook.Handler[*core.FilesystemDeleteEvent]{
// Func: func(e *core.FilesystemDeleteEvent) error {
// t.registerEventCall("OnFilesystemDelete")
// return e.Next()
// },
// Priority: -99999,
// })
// t.OnFilesystemNewWriter().Bind(&hook.Handler[*core.FilesystemNewWriterEvent]{
// Func: func(e *core.FilesystemNewWriterEvent) error {
// t.registerEventCall("OnFilesystemNewWriter")
// return e.Next()
// },
// Priority: -99999,
// })
t.OnRealtimeConnectRequest().Bind(&hook.Handler[*core.RealtimeConnectRequestEvent]{ t.OnRealtimeConnectRequest().Bind(&hook.Handler[*core.RealtimeConnectRequestEvent]{
Func: func(e *core.RealtimeConnectRequestEvent) error { Func: func(e *core.RealtimeConnectRequestEvent) error {
t.registerEventCall("OnRealtimeConnectRequest") t.registerEventCall("OnRealtimeConnectRequest")
+1 -1
View File
@@ -48,7 +48,7 @@ func TestCreateSuccess(t *testing.T) {
t.Fatalf("Expected zip with name %q, got %q", zipName, name) t.Fatalf("Expected zip with name %q, got %q", zipName, name)
} }
expectedSize := int64(544) expectedSize := int64(532)
if size := info.Size(); size != expectedSize { if size := info.Size(); size != expectedSize {
t.Fatalf("Expected zip with size %d, got %d", expectedSize, size) t.Fatalf("Expected zip with size %d, got %d", expectedSize, size)
} }
+1 -1
View File
@@ -2,7 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"fmt" "fmt"
+1 -1
View File
@@ -2,7 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"net/http" "net/http"
+3 -3
View File
@@ -3,7 +3,7 @@ package auth
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json/v2"
"testing" "testing"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -215,12 +215,12 @@ func TestExtra(t *testing.T) {
after := b.Extra() after := b.Extra()
rawExtra, err := json.Marshal(extra) rawExtra, err := json.Marshal(extra, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
rawAfter, err := json.Marshal(after) rawAfter, err := json.Marshal(after, json.Deterministic(true))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -2,7 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json" "encoding/json/v2"
"errors" "errors"
"io" "io"
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more