mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12bccaafc | ||
|
|
caee2a55f4 | ||
|
|
a47551804c | ||
|
|
86ce1be8fe | ||
|
|
457e094d41 | ||
|
|
5cec579da9 | ||
|
|
5f496665c7 | ||
|
|
adf4eb822f | ||
|
|
114c01ac12 | ||
|
|
5684ee24f1 | ||
|
|
b3de909790 | ||
|
|
a53f189f74 | ||
|
|
7ff7e0f043 | ||
|
|
6e29284e6a | ||
|
|
1984952e4e | ||
|
|
f611256d90 | ||
|
|
10f4f28312 | ||
|
|
44f817e7f1 | ||
|
|
75c6a4fd1f | ||
|
|
57c0b034e6 | ||
|
|
4709f63183 | ||
|
|
53a6cd04e2 | ||
|
|
df4e6eeb35 | ||
|
|
dbea21f1b2 | ||
|
|
97f9d63a1b | ||
|
|
bf12ae0244 | ||
|
|
7ac159b61b | ||
|
|
8213ccffb4 | ||
|
|
e16a9f8b21 | ||
|
|
0206f6135c | ||
|
|
e417a26a05 | ||
|
|
ac4c30ccbc | ||
|
|
fed5ab9a13 | ||
|
|
6d9ca9bf9d | ||
|
|
56f1d1dfdd | ||
|
|
1b3edbbf5c | ||
|
|
522f9e0ab0 | ||
|
|
3c9427667d | ||
|
|
fd5b9e5f75 | ||
|
|
d0b441bc24 | ||
|
|
229240b374 | ||
|
|
f694893d31 | ||
|
|
8cb486f24a | ||
|
|
bc8ffed4e7 | ||
|
|
da18302785 | ||
|
|
b402f11a79 | ||
|
|
9f4f4d4e73 | ||
|
|
d763d3dff1 | ||
|
|
04ed202c78 | ||
|
|
756b326bfa | ||
|
|
50f5f83ace | ||
|
|
70d585759d | ||
|
|
9e15b464ba | ||
|
|
129d46f4c8 | ||
|
|
eae29c83a8 | ||
|
|
c532fd19b6 | ||
|
|
eade68d4a3 | ||
|
|
18cb3dc62f | ||
|
|
a293e1301d | ||
|
|
7f4b532f06 | ||
|
|
5e5ba383e8 | ||
|
|
b648b951b0 | ||
|
|
f544fa0c81 | ||
|
|
80608e8687 | ||
|
|
654af12d17 | ||
|
|
0e9dcfe1ab | ||
|
|
25dc8a8eea | ||
|
|
bf1f164014 | ||
|
|
50a7700450 | ||
|
|
252358e036 | ||
|
|
c2cbd9707f | ||
|
|
e71be7194a | ||
|
|
1a5f29818c | ||
|
|
25081f14eb | ||
|
|
f5695a7308 | ||
|
|
bad7cc00a1 | ||
|
|
14fc20a851 | ||
|
|
97dd775455 | ||
|
|
4d4275c9aa | ||
|
|
1247b24236 | ||
|
|
5d217ddb50 | ||
|
|
e0169684c4 | ||
|
|
f7229c502f | ||
|
|
82368a6152 | ||
|
|
d744647929 | ||
|
|
c86fa8890d | ||
|
|
3668e1c431 | ||
|
|
a4f1fa33d1 | ||
|
|
2262bb4cae | ||
|
|
6212276c82 | ||
|
|
9f1a4bcfa5 | ||
|
|
32fa2ddf77 | ||
|
|
dc7482ea67 | ||
|
|
b259534df7 | ||
|
|
4c6c166603 | ||
|
|
f9795da8c7 | ||
|
|
200c19b26f | ||
|
|
fd25a8ae32 | ||
|
|
4dc4387638 | ||
|
|
4076537a2c | ||
|
|
0a74d2f25d | ||
|
|
7789297065 | ||
|
|
17628e554c | ||
|
|
8ec80c9828 | ||
|
|
e83e911b2f | ||
|
|
6b00152847 | ||
|
|
9f32dfa7ba | ||
|
|
0cbfc046c7 | ||
|
|
d02016c5fd | ||
|
|
9425a5c82f | ||
|
|
244ae1a162 | ||
|
|
8af99615c4 | ||
|
|
cc4e857090 | ||
|
|
ae2cb3c772 | ||
|
|
fe6811f824 | ||
|
|
77bd87b4d1 | ||
|
|
7964a8ce43 | ||
|
|
09af56b342 | ||
|
|
6aa6b2c471 | ||
|
|
a51a53db97 | ||
|
|
4ea2513b62 | ||
|
|
b419498d9c | ||
|
|
636b7e28d8 | ||
|
|
015ed81e05 | ||
|
|
2f802866f9 | ||
|
|
bea265cf26 | ||
|
|
dcb9cf3d0a | ||
|
|
f1618ee59b | ||
|
|
09044ef7a3 | ||
|
|
3c880378c9 | ||
|
|
c388ade7f0 | ||
|
|
7ec875a49e | ||
|
|
4bc71adc19 | ||
|
|
4221a1b803 | ||
|
|
0c7d243d97 | ||
|
|
089ca8ae41 | ||
|
|
de3c3f715b | ||
|
|
2128a95450 | ||
|
|
692e0f0789 | ||
|
|
e646b8ebac | ||
|
|
b5bed248d1 | ||
|
|
1cfc503bcd | ||
|
|
7327f0cf33 | ||
|
|
c220a61ef4 | ||
|
|
b2ed234d74 | ||
|
|
7b3f4d62b1 | ||
|
|
667a765070 | ||
|
|
61d2ca3e60 | ||
|
|
6d724ba6e0 | ||
|
|
71ea799e45 | ||
|
|
ed076328cb | ||
|
|
13bc3a12c0 | ||
|
|
3a9a341527 | ||
|
|
2d4d830203 | ||
|
|
be6671b2eb | ||
|
|
8d30cb57e2 | ||
|
|
507ecb264b | ||
|
|
07471c94d1 | ||
|
|
29b755243c | ||
|
|
c8b0c5884d | ||
|
|
1fa662bb92 | ||
|
|
25d0026686 | ||
|
|
a20ead67f1 | ||
|
|
2818208909 | ||
|
|
2e9cc27fc8 | ||
|
|
01dcb5aaf3 | ||
|
|
465cfb5293 | ||
|
|
f58a65e4be | ||
|
|
aa7be1edd1 | ||
|
|
f868756721 | ||
|
|
b66a4e32cc | ||
|
|
72df31e22d | ||
|
|
77639f94ce | ||
|
|
bff6b4c3ab | ||
|
|
aee115a99f | ||
|
|
b81d92dbd2 | ||
|
|
98a0f5eb05 | ||
|
|
3350ae651d | ||
|
|
394a46f3a2 | ||
|
|
4624e84b50 | ||
|
|
21dd105d9a | ||
|
|
8d60b928fc | ||
|
|
e7ae0bd716 | ||
|
|
4752ed84c7 | ||
|
|
5631d9b1c2 | ||
|
|
b36b8e35af | ||
|
|
9ff3e5a895 | ||
|
|
e5964e739e | ||
|
|
40d2849aa6 | ||
|
|
f3ae7731d4 | ||
|
|
1c3a912532 | ||
|
|
2b61b2babf | ||
|
|
d4b73af2e9 | ||
|
|
b11731222f | ||
|
|
f1873ad26d | ||
|
|
aeb78e51a1 | ||
|
|
541aa6b1e1 | ||
|
|
a9f259a1c7 | ||
|
|
a2d4f12071 | ||
|
|
846432f4c8 | ||
|
|
167e23e9ba | ||
|
|
ea2ef87d08 | ||
|
|
e067bf1517 | ||
|
|
97b85334a9 | ||
|
|
d4026ce60f | ||
|
|
40c631db32 | ||
|
|
095b0aca51 | ||
|
|
a7dfbbb8ec | ||
|
|
b65b7c5c56 | ||
|
|
4ebdfe4a1e | ||
|
|
19db35c48c | ||
|
|
1034dc9842 | ||
|
|
5bea84b94b | ||
|
|
7984e586a8 | ||
|
|
b022e138d4 | ||
|
|
db853850ab | ||
|
|
3d3d3b7355 | ||
|
|
3616b9d667 | ||
|
|
efd9604aa7 | ||
|
|
1ab6bba61b | ||
|
|
9e6b4eeda8 | ||
|
|
ea1a537a60 | ||
|
|
23a989118c | ||
|
|
d0b2551e78 | ||
|
|
b9b0e5ae80 | ||
|
|
f7fbc6c2c3 | ||
|
|
a286d28bf9 | ||
|
|
5cff808438 | ||
|
|
45e3ca6ea6 | ||
|
|
9c8557a124 | ||
|
|
8d7e3abbd6 | ||
|
|
9d50e20880 | ||
|
|
fbf4f3e5fb | ||
|
|
820b9afe98 | ||
|
|
b061673d9a | ||
|
|
c9c47dc75c | ||
|
|
cbb7c061a3 | ||
|
|
caad25bb61 | ||
|
|
3b98059a8a | ||
|
|
a8c236a54d | ||
|
|
81a5672498 | ||
|
|
1b5ea9a1fa |
+34
-7
@@ -2,19 +2,26 @@
|
||||
|
||||
**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io** or submit a private [GitHub Security advisory](https://github.com/pocketbase/pocketbase/security/advisories).
|
||||
If you want to responsibly report a security issue you'll have to reach out as a human to **support at pocketbase.io**.
|
||||
|
||||
I try to be as responsive as possible and usually address security reports within a day or two, but if you didn't receive a reply from me for more than 5 days it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
This means:
|
||||
- no overconfident and arrogant tone
|
||||
- no threatening deadlines
|
||||
- no requirement for me to login in your security platform just to read the report
|
||||
- no inflated severity (we can discuss the CVSS score after confirming the issue)
|
||||
- no LLMs usage as part of your report description or followup communication
|
||||
|
||||
In case the vulnerability is confirmed, within another couple days I'll try to submit a fix, GitHub security advisory and CVE with remediation steps and **minimal details** regarding the found exploit to minimize giving too much hints to malicious actors (you'll be credited both in the fix release notes and in the public report).
|
||||
Reports that don't follow the above will NOT be reviewed no matter of their validity _(you are of course free to publish whatever you want; see also [#7718](https://github.com/pocketbase/pocketbase/discussions/7718))_.
|
||||
|
||||
### Please:
|
||||
**Or in other words - a simple _"Hey I think I found a security issue when I do X"_ is enough.**
|
||||
|
||||
- DO NOT use LLM as part of your report or email communication - it is extremely frustrating to spend an hour or more reading a wall of generated text, writing an elaborate reply and then to receive another generic LLM prompt response in return.
|
||||
I try to be as responsive as possible and usually address security issues within couple days but if you didn't receive a reply from me for more than a week it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
|
||||
- DO NOT reserve and publish MITRE CVE number on your own _(I prefer to do it through the GitHub Security advisory)_ and try to communicate first privately the details to better understand how the code is being used and whether the supposed vulnerability can be actually exploited in any real practical scenarios. Otherwise you are risking needlessly causing scaremongering and annoyance for users that rely on security scanners as part of their CI/CD pipeline.
|
||||
In case the vulnerability is confirmed:
|
||||
|
||||
- Wait before publicly disclosing and sharing details about the found vulnerability, **ideally at least 5 days after the fix**, to make it harder to exploit and give enough time for users to patch their instances _(you are free to provide a PoC and as much details as you want in your own blog/gist/etc.)_.
|
||||
- I'll start working on a local fix.
|
||||
- Once the fix is implemented locally, I'll publish a pre-announcement with a scheduled release date _(and when possible an approximate release time)_.
|
||||
- After the release, I'll publish a GitHub security advisory and CVE with remediation steps and **minimal** details regarding the found exploit _(you are free to publish PoC and more details in your own blog, gist, etc. but it is advised to wait at least a week after the release to allow enough time for people to patch their instances before making it more publicly known)_.
|
||||
|
||||
### Below is a short list of previous reports that are NOT considered security issues:
|
||||
|
||||
@@ -84,6 +91,14 @@ In many places where applicable we've tried to minimize the impact by using cons
|
||||
If you think that there is a place where we can improve the handling without hurting too much the user experience, feel free to open a regular public issue and it will be considered.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Attack-vectors relying on social engineering</strong></summary>
|
||||
|
||||
Reports for attacks relying on various social engineering tactics _(e.g. tricking someone to click on a link)_ are valid concerns but usually out of the security scope of the project as there are a lot of cases where the APIs are deliberately designed for minimal friction.
|
||||
|
||||
If you have concerns for such attack, feel free to open a regular public issue and we can eventually try to reconsider adding extra guards when feasible _(or at least properly document the existing behavior)_.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
|
||||
|
||||
@@ -99,3 +114,15 @@ Third, even if that issue is still available, with PocketBase it would have been
|
||||
|
||||
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>JSVM "sandboxing"</strong></summary>
|
||||
|
||||
This is another very common report but **there is no such thing as JSVM "sandboxing" in PocketBase**.
|
||||
|
||||
The JS `pb_hooks` (or JSVM for short) are NOT supposed to run untrusted or client provided JavaScript code _(the same way you are not supposed to run untrusted code in your Node.js server)_.
|
||||
|
||||
Once interpreted the `pb_hooks` run as part of the same application process together with the rest of the Go code. There are no additional filesystem, network, memory, etc. restrictions. This means that it is OK for developers to be able to access environment variables, perform network calls to any URLs they want, invoke shell commands or even sleep/block the script execution.
|
||||
|
||||
So if you are security researcher and not sure if something is a security fault in the JS hooks, ask yourself - "Can I do the same when using PocketBase as Go framework?" and if the answer is "Yes" then it is not a security issue with the JSVM.
|
||||
</details>
|
||||
|
||||
@@ -26,9 +26,9 @@ jobs:
|
||||
node-version: '>=25.2.1'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '>=1.26.3'
|
||||
go-version: '>=1.27.1'
|
||||
|
||||
# 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.
|
||||
|
||||
+56
-1025
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,80 @@
|
||||
> 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
|
||||
|
||||
- (_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).
|
||||
|
||||
|
||||
## v0.22.51
|
||||
|
||||
- (_Backported from v0.39.10_) Reverted the auto panic recover handling for the cli commands to preserve the old behavior and allow panic to force exit with non-zero code ([#7781](https://github.com/pocketbase/pocketbase/issues/7781)).
|
||||
|
||||
|
||||
## v0.22.50
|
||||
|
||||
- (_Backported from v0.39.9_) Bumped goja, fexpr and their related deps.
|
||||
|
||||
|
||||
## v0.22.49
|
||||
|
||||
- (_Backported from v0.39.8_) Bumped `golang.org/x/*` indirect dependencies as there are some minor security fixes.
|
||||
|
||||
- (_Backported from v0.39.8_) Updated `modernc.org/sqlite` to v1.54.0 ([SQLite 3.53.3](https://sqlite.org/src/timeline?from=version-3.53.2&to=version-3.53.3&to2=branch-3.53)).
|
||||
|
||||
|
||||
## v0.22.48
|
||||
|
||||
- (_Backported from v0.39.7_) Replaced `github.com/go-ozzo/ozzo-validation` with the fork `github.com/pocketbase/ozzo-validation` since the original library has recently changed ownership and the new maintainer cannot be trusted.
|
||||
|
||||
- (_Backported from v0.39.7_) Fixed View collection `*` validator and added more friendly error messages ([#7761](https://github.com/pocketbase/pocketbase/issues/7761)).
|
||||
|
||||
- (_Backported from v0.39.7_) ⚠️ Security fix for unhandled panic in internal worker goroutines ([#7762](https://github.com/pocketbase/pocketbase/discussions/7762)).
|
||||
|
||||
|
||||
## v0.22.47
|
||||
|
||||
- (_Backported from v0.39.6_) Bumped the min Go GitHub action version to 1.26.5 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.5).
|
||||
|
||||
|
||||
## v0.22.46
|
||||
|
||||
- (_Backported from v0.39.1_) Bumped the min Go GitHub action version to 1.26.4 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.4).
|
||||
|
||||
|
||||
## v0.22.45
|
||||
|
||||
- (_Backported from v0.38.2_) Updated all `golang.org/x/` packages to cover the recent [security fixes](https://groups.google.com/g/golang-announce/c/PdiGK3xulk4) _(none of them should be a critical issue in PocketBase but nonetheless it is advised to update)_.
|
||||
|
||||
|
||||
## v0.22.44
|
||||
|
||||
- (_Backported from v0.38.1_) Force unset the auth state of existing realtime connections on user tokenKey change.
|
||||
|
||||
|
||||
## v0.22.43
|
||||
|
||||
- (_Backported from v0.38.0_) Bumped min Go GitHub action version to 1.26.3 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.3).
|
||||
|
||||
+1243
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,7 +13,7 @@ This document describes how to prepare a PR for a change in the main repository.
|
||||
|
||||
## 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)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
|
||||
|
||||
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:
|
||||
```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:
|
||||
|
||||
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
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
3. Run `CGO_ENABLED=0 go build` to build a binary for your current 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`.
|
||||
|
||||
Note that the supported build targets by the pure Go SQLite driver at the moment are:
|
||||
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
freebsd amd64
|
||||
freebsd arm64
|
||||
linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux loong64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows 386
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
| GOOS | GOARCH |
|
||||
|---------|---------|
|
||||
| darwin | amd64 |
|
||||
| darwin | arm64 |
|
||||
| freebsd | 386 |
|
||||
| freebsd | amd64 |
|
||||
| freebsd | arm |
|
||||
| freebsd | arm64 |
|
||||
| linux | 386 |
|
||||
| linux | amd64 |
|
||||
| linux | arm |
|
||||
| linux | arm64 |
|
||||
| linux | loong64 |
|
||||
| linux | ppc64le |
|
||||
| linux | riscv64 |
|
||||
| linux | s390x |
|
||||
| netbsd | amd64 |
|
||||
| openbsd | amd64 |
|
||||
| openbsd | arm64 |
|
||||
| windows | 386 |
|
||||
| windows | amd64 |
|
||||
| windows | arm64 |
|
||||
|
||||
### 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**.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
|
||||
@@ -48,6 +48,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindBatchApi(app, apiGroup)
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
bindHealthApi(app, apiGroup)
|
||||
bindSQLApi(app, apiGroup)
|
||||
|
||||
// UI routes
|
||||
bindUIExtensions(app)
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@ package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -195,7 +196,7 @@ func (p *batchProcessor) Process(batch []*core.InternalRequest, timeout time.Dur
|
||||
p.stopCh <- struct{}{}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
routine.FireAndForget(func() {
|
||||
err := p.process(txApp, batch, 0)
|
||||
|
||||
if err != nil {
|
||||
@@ -216,7 +217,7 @@ func (p *batchProcessor) Process(batch []*core.InternalRequest, timeout time.Dur
|
||||
}
|
||||
|
||||
p.errCh <- err
|
||||
}()
|
||||
})
|
||||
|
||||
select {
|
||||
case responseErr := <-p.errCh:
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
|
||||
+66
-1
@@ -1225,6 +1225,71 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"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
|
||||
// -----------------------------------------------------------
|
||||
@@ -1617,7 +1682,7 @@ func TestCollectionOAuth2Providers(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`,
|
||||
`{"name":"oidc3","displayName":"OIDC","logo":"<svg`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"order":`,
|
||||
|
||||
+3
-4
@@ -77,10 +77,13 @@ func TestCronsRun(t *testing.T) {
|
||||
app.Cron().Add("test", "* * * * *", func() {
|
||||
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) {
|
||||
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
total := cast.ToInt(app.Store().Get("testJobCalls"))
|
||||
if total != expected {
|
||||
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
|
||||
@@ -93,7 +96,6 @@ func TestCronsRun(t *testing.T) {
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 401,
|
||||
@@ -107,7 +109,6 @@ func TestCronsRun(t *testing.T) {
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 403,
|
||||
@@ -121,7 +122,6 @@ func TestCronsRun(t *testing.T) {
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 404,
|
||||
@@ -135,7 +135,6 @@ func TestCronsRun(t *testing.T) {
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(1),
|
||||
ExpectedStatus: 204,
|
||||
|
||||
+2
-1
@@ -81,7 +81,8 @@ func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
|
||||
defer f.Close()
|
||||
|
||||
// wrap in a self-executing function to avoid scope and concatenation issues
|
||||
_, _ = buf.WriteString("(function(){")
|
||||
// (the await/async is for top-level await)
|
||||
_, _ = buf.WriteString("await (async function(){")
|
||||
|
||||
_, err = io.Copy(buf, f)
|
||||
if err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestUIExtensions_Mainjs(t *testing.T) {
|
||||
},
|
||||
AfterTestFunc: successAfterTestFunc,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
|
||||
ExpectedContent: []string{"await (async function(){ext1_main})();await (async function(){ext3_main})();"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ func healthCheck(e *core.RequestEvent) error {
|
||||
headersToCheck := append(
|
||||
slices.Clone(e.App.Settings().TrustedProxy.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 {
|
||||
if e.Request.Header.Get(header) != "" {
|
||||
|
||||
+22
-1
@@ -11,8 +11,13 @@ import (
|
||||
|
||||
// bindLogsApi registers the request logs api endpoints.
|
||||
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.DELETE("", logsTruncate)
|
||||
sub.GET("/stats", logsStats)
|
||||
sub.GET("/{id}", logsView)
|
||||
}
|
||||
@@ -71,3 +76,19 @@ func logsView(e *core.RequestEvent) error {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -210,3 +210,59 @@ func TestLogsStats(t *testing.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-Content-Type-Options", "nosniff")
|
||||
e.Response.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
e.Response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
|
||||
// @todo consider a default HSTS?
|
||||
// (see also https://webkit.org/blog/8146/protecting-against-hsts-abuse/)
|
||||
|
||||
@@ -84,49 +84,73 @@ func applyBodyLimit(e *core.RequestEvent, limitBytes int64) error {
|
||||
}
|
||||
|
||||
// replace the request body
|
||||
//
|
||||
// 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}
|
||||
e.Request.Body = newMaxBytesReader(e.Request.Body, limitBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
io.ReadCloser
|
||||
limit int64
|
||||
totalRead int64
|
||||
func newMaxBytesReader(body io.ReadCloser, limitBytes int64) *maxBytesReader {
|
||||
return &maxBytesReader{
|
||||
ReadCloser: body,
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
if int64(n) <= r.remaining {
|
||||
r.remaining -= int64(n)
|
||||
r.stickyErr = err
|
||||
return n, err
|
||||
}
|
||||
|
||||
r.totalRead += int64(n)
|
||||
if r.totalRead > r.limit {
|
||||
return n, ErrRequestEntityTooLarge
|
||||
}
|
||||
n = int(r.remaining)
|
||||
|
||||
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
|
||||
// (extra precautions in case of nested interface wrapping erasure)
|
||||
// ---
|
||||
|
||||
func (r *limitedReader) Reread() {
|
||||
func (r *maxBytesReader) Reread() {
|
||||
rereader, ok := r.ReadCloser.(router.Rereader)
|
||||
if ok {
|
||||
rereader.Reread()
|
||||
r.remaining = r.limit
|
||||
r.stickyErr = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Close() error {
|
||||
closer, ok := r.ReadCloser.(io.Closer)
|
||||
if ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
func (r *maxBytesReader) Close() error {
|
||||
return r.ReadCloser.Close()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -19,13 +20,35 @@ func TestBodyLimitMiddleware(t *testing.T) {
|
||||
if err != nil {
|
||||
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 {
|
||||
return e.String(200, "b")
|
||||
}).Bind(apis.BodyLimit(20))
|
||||
testHandler := func(e *core.RequestEvent) error {
|
||||
// read the body multiple times to ensure that the limited
|
||||
// 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()
|
||||
if err != nil {
|
||||
@@ -33,20 +56,90 @@ func TestBodyLimitMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
size int64
|
||||
expectedStatus int
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
lazyContentLength bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/a", 21, 200},
|
||||
{"/a", apis.DefaultMaxBodySize + 1, 413},
|
||||
{"/b", 20, 200},
|
||||
{"/b", 21, 413},
|
||||
{
|
||||
"(eager content-length check) with body = default limit",
|
||||
"/a",
|
||||
`"` + 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 {
|
||||
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()
|
||||
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)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -127,27 +129,29 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/guest", 0, true, 429},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.url, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
for i, s := range scenarios {
|
||||
prefix := fmt.Sprintf("[%s:%d] ", s.url, i+1)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", s.url, nil)
|
||||
|
||||
if s.authenticated {
|
||||
auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf(prefix+"%v", err)
|
||||
}
|
||||
|
||||
token, err := auth.NewAuthToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf(prefix+"%v", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
synctest.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
@@ -155,10 +159,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
result := rec.Result()
|
||||
|
||||
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) {
|
||||
|
||||
+144
-23
@@ -2,7 +2,7 @@ package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/picker"
|
||||
@@ -28,6 +28,9 @@ const clientsChunkSize = 150
|
||||
// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
|
||||
const RealtimeClientAuthKey = "auth"
|
||||
|
||||
// RealtimeClientIPKey is the name of the realtime client store key that holds the IP of the connected client.
|
||||
const RealtimeClientIPKey = "pbRealtimeClientIP"
|
||||
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
sub := rg.Group("/realtime")
|
||||
@@ -63,8 +66,12 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
|
||||
connectEvent := new(core.RealtimeConnectRequestEvent)
|
||||
connectEvent.RequestEvent = e
|
||||
connectEvent.Client = subscriptions.NewDefaultClient()
|
||||
connectEvent.IdleTimeout = 5 * time.Minute
|
||||
connectEvent.MaxTimeout = 30 * time.Minute
|
||||
connectEvent.Client = subscriptions.NewDefaultClient()
|
||||
|
||||
// could be used as an optional cross-reference check in other API endpoints
|
||||
connectEvent.Client.Set(RealtimeClientIPKey, e.RealIP())
|
||||
|
||||
return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
|
||||
// register new subscription client
|
||||
@@ -73,7 +80,7 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
e.App.SubscriptionsBroker().Unregister(ce.Client.Id())
|
||||
}()
|
||||
|
||||
ce.App.Logger().Debug("Realtime connection established.", slog.String("clientId", ce.Client.Id()))
|
||||
ce.App.Logger().Debug("Realtime connection established", slog.String("clientId", ce.Client.Id()))
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
connectMsgEvent := new(core.RealtimeMessageEvent)
|
||||
@@ -99,12 +106,19 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// start a max lifetime timer to prevent accumulating too much
|
||||
// connection resources and to allow the GC to run more regularly
|
||||
maxTimer := time.NewTimer(ce.MaxTimeout)
|
||||
defer maxTimer.Stop()
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleTimer := time.NewTimer(ce.IdleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-maxTimer.C:
|
||||
cancelRequest()
|
||||
case <-idleTimer.C:
|
||||
cancelRequest()
|
||||
case msg, ok := <-ce.Client.Channel():
|
||||
@@ -186,6 +200,21 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
return e.NotFoundError("Missing or invalid client id.", err)
|
||||
}
|
||||
|
||||
// for just in case to prevent someone changing a guest subscription
|
||||
//
|
||||
// note1: this is an extra precaution against clientId bruteforce attempts
|
||||
// for installations allowing longer realtime connections duration
|
||||
//
|
||||
// note2: custom registered clients (aka. those without IP in the store)
|
||||
// are excluded from the check for backward compatibility
|
||||
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
|
||||
if clientIP != "" && clientIP != e.RealIP() {
|
||||
return e.BadRequestError(
|
||||
"Invalid realtime client.",
|
||||
errors.New("the subscription request IP doesn't match with the realtime client IP"),
|
||||
)
|
||||
}
|
||||
|
||||
// for now allow only guest->auth upgrades and any other auth change is forbidden
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
|
||||
@@ -208,7 +237,7 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
e.App.Logger().Debug(
|
||||
"Realtime subscriptions updated.",
|
||||
"Realtime subscriptions updated",
|
||||
slog.String("clientId", e.Client.Id()),
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
@@ -219,38 +248,47 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
|
||||
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
||||
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
|
||||
//
|
||||
// Realtime connections has short lifetime by design, but to minimize abuse
|
||||
// if the new record has a different tokenKey (e.g. in case of password reset)
|
||||
// the auth state of the related realtime connections is also cleared
|
||||
// (aka. they remain active but unauthenticated, allowing to reauthenicate with the next subscription).
|
||||
func realtimeUpdateClientsAuth(app core.App, authRecord *core.Record) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
for _, client := range chunk {
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil &&
|
||||
clientAuth.Id == newAuthRecord.Id &&
|
||||
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
|
||||
client.Set(RealtimeClientAuthKey, newAuthRecord)
|
||||
clientAuth.Id == authRecord.Id &&
|
||||
clientAuth.Collection().Name == authRecord.Collection().Name {
|
||||
if clientAuth.TokenKey() != authRecord.TokenKey() {
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
} else {
|
||||
client.Set(RealtimeClientAuthKey, authRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetClientsAuthState unsets the auth state of all clients that have the provided auth model.
|
||||
func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
||||
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
|
||||
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
for _, client := range chunk {
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil &&
|
||||
@@ -261,13 +299,82 @@ func realtimeUnsetClientsAuthState(app core.App, authModel core.Model) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetClientsAuthByCollection unsets the auth state of all authenticated clients related to the collection.
|
||||
func realtimeUnsetClientsAuthByCollection(app core.App, collection *core.Collection) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
for _, client := range chunk {
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil && clientAuth.Collection().Name == collection.Name {
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
func bindRealtimeEvents(app core.App) {
|
||||
// reset the clients auth on collection secret change
|
||||
// (@todo with the future tracking of old collections data consider replacing with *AfterUpdateSuccess to account for transaction rollback)
|
||||
app.OnCollectionUpdate().Bind(&hook.Handler[*core.CollectionEvent]{
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
if !e.Collection.IsAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
cached, _ := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cached != nil && cached.AuthToken.Secret != e.Collection.AuthToken.Secret {
|
||||
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the changed auth collection",
|
||||
slog.String("collectionName", e.Collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Priority: -99,
|
||||
})
|
||||
|
||||
// unset the clients auth on auth collection delete
|
||||
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
if e.Collection.IsAuth() {
|
||||
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the deleted auth collection",
|
||||
slog.String("collectionName", e.Collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
Priority: -99,
|
||||
})
|
||||
|
||||
// update the clients that has auth record association
|
||||
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
@@ -294,7 +401,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil && collection.IsAuth() {
|
||||
if err := realtimeUnsetClientsAuthState(e.App, e.Model); err != nil {
|
||||
if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the deleted auth model",
|
||||
slog.Any("id", e.Model.PK()),
|
||||
@@ -516,7 +623,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
var clientAuth *core.Record
|
||||
|
||||
for _, client := range chunk {
|
||||
@@ -548,6 +655,20 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
// which exact fields the client subscription requested or has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// -------------------------------------------
|
||||
// @todo consider with the refactoring whether
|
||||
// the default enriching used by the regular APIs
|
||||
// can be reused here too to avoid eventual future
|
||||
// discrepencies in the record event data
|
||||
//
|
||||
// https://github.com/pocketbase/pocketbase/issues/7721
|
||||
// -------------------------------------------
|
||||
|
||||
// enable hidden fields for superuser subscribers
|
||||
if requestInfo.HasSuperuserAuth() {
|
||||
cleanRecord.Unhide(collection.Fields.FieldNames()...)
|
||||
}
|
||||
|
||||
// trigger the enrich hooks
|
||||
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
||||
// apply expand
|
||||
@@ -645,7 +766,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
@@ -661,7 +782,7 @@ func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
for _, client := range chunk {
|
||||
messages, ok := client.Get(key).([]subscriptions.Message)
|
||||
if !ok {
|
||||
@@ -680,7 +801,7 @@ func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
@@ -696,7 +817,7 @@ func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
group.Go(routine.SafeWrap(func() error {
|
||||
for _, client := range chunk {
|
||||
if client.Get(key) != nil {
|
||||
client.Unset(key)
|
||||
@@ -704,7 +825,7 @@ func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
|
||||
+375
-19
@@ -2,7 +2,7 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -26,6 +26,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`id:`,
|
||||
@@ -37,6 +38,17 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeMessageSend": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
app.OnRealtimeConnectRequest().BindFunc(func(e *core.RealtimeConnectRequestEvent) error {
|
||||
if ip, _ := e.Client.Get(apis.RealtimeClientIPKey).(string); ip != "127.0.0.2" {
|
||||
t.Fatalf("Expected IP %q, got %q", "127.0.0.2", ip)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
@@ -102,7 +114,8 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
|
||||
resetClient := func() {
|
||||
client.Unsubscribe()
|
||||
client.Set(apis.RealtimeClientAuthKey, nil)
|
||||
client.Unset(apis.RealtimeClientAuthKey)
|
||||
client.Unset(apis.RealtimeClientIPKey)
|
||||
}
|
||||
|
||||
validSubscriptionsLimit := make([]string, 1000)
|
||||
@@ -208,6 +221,26 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "existing client with different IP",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test"]}`),
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
client.Set(apis.RealtimeClientIPKey, "127.0.0.1")
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
resetClient()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client with valid topic length",
|
||||
Method: http.MethodPost,
|
||||
@@ -429,7 +462,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -460,7 +496,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
e.Context = context.Background()
|
||||
e.Model = authRecord1
|
||||
|
||||
testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||
@@ -484,7 +523,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -495,25 +537,331 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord and change its email
|
||||
// refetch the authRecord and change its name
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
// mock update event
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeUpdate
|
||||
e.Context = context.Background()
|
||||
e.Model = authRecord2
|
||||
newName := "test_new_name"
|
||||
authRecord2.Set("name", newName)
|
||||
|
||||
testApp.OnModelAfterUpdateSuccess().Trigger(e)
|
||||
err = testApp.Save(authRecord2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
|
||||
if clientAuthRecord.Get("name") != newName {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", newName, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeRecordHiddenFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create temp collection with hidden fields
|
||||
testCollection := core.NewBaseCollection("test_realtime")
|
||||
testCollection.ListRule = types.Pointer("@request.auth.id != ''")
|
||||
testCollection.Fields.Add(
|
||||
&core.TextField{Name: "public"},
|
||||
&core.TextField{Name: "hidden", Hidden: true},
|
||||
)
|
||||
if err := testApp.Save(testCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testSubscription := testCollection.Name + "/*"
|
||||
|
||||
// register guest subscriber
|
||||
guestClient := subscriptions.NewDefaultClient()
|
||||
guestClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(guestClient)
|
||||
|
||||
// register regular user subscriber
|
||||
regular, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regularClient := subscriptions.NewDefaultClient()
|
||||
regularClient.Set(apis.RealtimeClientAuthKey, regular)
|
||||
regularClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(regularClient)
|
||||
|
||||
// register superuser subscriber
|
||||
superuser, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
superuserClient := subscriptions.NewDefaultClient()
|
||||
superuserClient.Set(apis.RealtimeClientAuthKey, superuser)
|
||||
superuserClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(superuserClient)
|
||||
|
||||
enrichCalls := map[string]int{}
|
||||
testApp.OnRecordEnrich(testCollection.Name).BindFunc(func(e *core.RecordEnrichEvent) error {
|
||||
var id string
|
||||
if e.RequestInfo.Auth != nil {
|
||||
id = e.RequestInfo.Auth.Id
|
||||
}
|
||||
enrichCalls[id]++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
timeout := time.After(3 * time.Second)
|
||||
done := make(chan struct{})
|
||||
|
||||
// collect first received messages
|
||||
var regularMessageData, superuserMessageData string
|
||||
go func() {
|
||||
regularMessageData = string((<-regularClient.Channel()).Data)
|
||||
superuserMessageData = string((<-superuserClient.Channel()).Data)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
// broadcast create message
|
||||
testRecord := core.NewRecord(testCollection)
|
||||
testRecord.Set("public", "test1")
|
||||
testRecord.Set("hidden", "test2")
|
||||
if err := testApp.Save(testRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// wait for the events
|
||||
select {
|
||||
case <-timeout:
|
||||
t.Fatal("realtime test messages timeout")
|
||||
case <-done:
|
||||
// ready
|
||||
}
|
||||
|
||||
if total := len(enrichCalls); total != 2 {
|
||||
t.Fatalf("Expected %d enrich hook calls, got %d", 2, total)
|
||||
}
|
||||
|
||||
if total := enrichCalls[regular.Id]; total != 1 {
|
||||
t.Fatalf("Expected exactly 1 regular user enrich hook call, got %d", total)
|
||||
}
|
||||
|
||||
if total := enrichCalls[superuser.Id]; total != 1 {
|
||||
t.Fatalf("Expected exactly 1 superuser enrich hook call, got %d", total)
|
||||
}
|
||||
|
||||
// validate messages content
|
||||
scenarios := map[string]bool{
|
||||
"regular message public field should exist": strings.Contains(regularMessageData, `"public":`),
|
||||
"regular message hidden field should NOT exist": !strings.Contains(regularMessageData, `"hidden":`),
|
||||
"superuser message public field should exist": strings.Contains(superuserMessageData, `"public":`),
|
||||
"superuser message hidden field should exist": strings.Contains(superuserMessageData, `"hidden":`),
|
||||
}
|
||||
for name, valid := range scenarios {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if !valid {
|
||||
t.Fatal("Invalid realtime message expectation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord and refresh its tokenKey
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authRecord2.RefreshTokenKey()
|
||||
|
||||
err = testApp.Save(authRecord2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuthRecord != nil {
|
||||
t.Fatalf("Expected authRecord to be unset, got %q", clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnCollectionSecretChange(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
usersCollection, err := testApp.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
|
||||
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
|
||||
|
||||
clientMocks := map[*core.Record]subscriptions.Client{
|
||||
authRecord1: client1,
|
||||
authRecord2: client2,
|
||||
authRecord3: client3,
|
||||
}
|
||||
for _, client := range clientMocks {
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
}
|
||||
|
||||
// change the secret of the users collection (should trigger unset)
|
||||
usersCollection.AuthToken.Secret = strings.Repeat("a", 30)
|
||||
err = testApp.Save(usersCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// change something else of the clients collection (shouldn't trigger unset)
|
||||
clientsCollection.ListRule = nil
|
||||
err = testApp.Save(clientsCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectations := map[*core.Record]bool{
|
||||
// record -> unset
|
||||
authRecord1: true,
|
||||
authRecord2: true,
|
||||
authRecord3: false,
|
||||
}
|
||||
for record, expectedUnset := range expectations {
|
||||
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
unset := clientAuthRecord == nil
|
||||
if unset != expectedUnset {
|
||||
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnCollectionDelete(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
usersCollection, err := testApp.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
|
||||
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
|
||||
|
||||
clientMocks := map[*core.Record]subscriptions.Client{
|
||||
authRecord1: client1,
|
||||
authRecord2: client2,
|
||||
authRecord3: client3,
|
||||
}
|
||||
for _, client := range clientMocks {
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
}
|
||||
|
||||
// mock users collection delete event to avoid triggering constraints check
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeDelete
|
||||
e.Context = context.Background()
|
||||
e.Model = usersCollection
|
||||
|
||||
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectations := map[*core.Record]bool{
|
||||
// record -> unset
|
||||
authRecord1: true,
|
||||
authRecord2: true,
|
||||
authRecord3: false,
|
||||
}
|
||||
for record, expectedUnset := range expectations {
|
||||
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
unset := clientAuthRecord == nil
|
||||
if unset != expectedUnset {
|
||||
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +899,10 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -608,7 +959,10 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -817,7 +1171,9 @@ func TestRealtimeRecordResolve(t *testing.T) {
|
||||
|
||||
var mu sync.Mutex
|
||||
notify := func(clientId string, eventData []byte) {
|
||||
data := struct{ Action string }{}
|
||||
data := struct {
|
||||
Action string `json:"action"`
|
||||
}{}
|
||||
_ = json.Unmarshal(eventData, &data)
|
||||
|
||||
mu.Lock()
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
`"providers":[{`,
|
||||
`"name":"google"`,
|
||||
`"name":"gitlab"`,
|
||||
`"logo":"\u003csvg`,
|
||||
`"logo":"<svg`,
|
||||
`"logo":""`, // for the legacy fields
|
||||
`"state":`,
|
||||
`"displayName":`,
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
|
||||
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
@@ -45,6 +45,13 @@ func recordConfirmVerification(e *core.RequestEvent) error {
|
||||
if !wasVerified {
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
// similar to the OTP auth, we enforce an extra password reset
|
||||
// guard as this way is less prone to pre-hijacking attacks
|
||||
// in case the password auth is eventually enabled later
|
||||
if !e.Record.Collection().PasswordAuth.Enabled {
|
||||
e.Record.SetRandomPassword()
|
||||
}
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatalf("Expected the user to be unverified before the confirmation")
|
||||
t.Fatal("Expected the user to be unverified before the confirmation")
|
||||
}
|
||||
|
||||
// ensure that there is at least one pre-existing OAuth2 link
|
||||
@@ -152,6 +152,85 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token (disabled password auth)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmVerificationRequest": 1,
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// unverified->verified external auths removal
|
||||
"OnModelDelete": 2,
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user.Collection().PasswordAuth.Enabled = false
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatal("Expected the user to be unverified before the confirmation")
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatal("Expected password to be valid")
|
||||
}
|
||||
|
||||
// ensure that there is at least one pre-existing OAuth2 link
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) == 0 {
|
||||
t.Fatal("Expected at least one external auths")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to be verified after the confirmation")
|
||||
}
|
||||
|
||||
if user.ValidatePassword("1234567890") {
|
||||
t.Fatal("Expected the user password to be reset")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token (already verified)",
|
||||
Method: http.MethodPost,
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
@@ -203,7 +203,6 @@ func (form *recordOAuth2LoginForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.RedirectURL, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -55,6 +55,16 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
}
|
||||
defer client.Unsubscribe(oauth2SubscriptionTopic)
|
||||
|
||||
// additional check to minimize the risk of XSRF attack vectors
|
||||
//
|
||||
// note: custom registered clients (aka. those without IP in the store)
|
||||
// are excluded from the check for backward compatibility
|
||||
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
|
||||
if clientIP != "" && clientIP != e.RealIP() {
|
||||
e.App.Logger().Debug("The client IP that completed the authentication is different from the one that initialized the OAuth2 realtime connection")
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/7090)
|
||||
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -16,9 +17,9 @@ import (
|
||||
func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
|
||||
clientStubs := make([]map[string]subscriptions.Client, 0, 11)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := 0; i < 11; i++ {
|
||||
c1 := subscriptions.NewDefaultClient()
|
||||
|
||||
c2 := subscriptions.NewDefaultClient()
|
||||
@@ -335,6 +336,28 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client with different IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[10]["c3"].Id(),
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
clientStubs[10]["c3"].Set(apis.RealtimeClientIPKey, "127.0.0.1")
|
||||
|
||||
beforeTestFunc(clientStubs[10], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[10]["c3"].Id(), `"code":"123"`},
|
||||
})(t, app, e)
|
||||
},
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -90,10 +90,11 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"provider":`,
|
||||
`"code":`,
|
||||
`"redirectURL":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"codeVerifier":`, // should be optional
|
||||
// should be optional
|
||||
`"codeVerifier":`,
|
||||
`"redirectURL":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
@@ -109,10 +110,11 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"provider":`,
|
||||
`"code":`,
|
||||
`"redirectURL":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"codeVerifier":`, // should be optional
|
||||
// should be optional
|
||||
`"codeVerifier":`,
|
||||
`"redirectURL":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
otpId := e.OTP.Id
|
||||
otpSentTo := e.OTP.SentTo()
|
||||
|
||||
// eagerly delete the OTP to avoid unnecessery double delete model hook calls
|
||||
// eagerly delete the OTP to avoid unnecessary double delete model hook calls
|
||||
// triggered by the password change below
|
||||
err := e.App.Delete(e.OTP)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package apis
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
const (
|
||||
runSQLMaxRows = 1000
|
||||
runSQLMaxTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
// bindSQLApi registers the SQL api endpoints.
|
||||
func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
|
||||
subGroup.POST("", runSQL)
|
||||
}
|
||||
|
||||
func runSQL(e *core.RequestEvent) error {
|
||||
// extra precaution in case manually invoked from somewhere else
|
||||
if !e.HasSuperuserAuth() {
|
||||
return e.ForbiddenError("", nil)
|
||||
}
|
||||
|
||||
form := runSQLForm{}
|
||||
|
||||
err := e.BindBody(&form)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
err = form.validate()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
result, err := executeQuery(e.App, form.Query, runSQLMaxRows)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
type runSQLForm struct {
|
||||
Query string `form:"query" json:"query"`
|
||||
}
|
||||
|
||||
func (form *runSQLForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
|
||||
)
|
||||
}
|
||||
|
||||
type runSQLResultColumn struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
}
|
||||
|
||||
type runSQLResult struct {
|
||||
ExecTime int64 `json:"execTime"`
|
||||
AffectedRows int64 `json:"affectedRows"`
|
||||
Columns []runSQLResultColumn `json:"columns"`
|
||||
Rows [][]any `json:"rows"`
|
||||
}
|
||||
|
||||
var knownWriteQueryPrefixes = []string{
|
||||
"INSERT", "CREATE", "UPDATE", "DELETE",
|
||||
"DROP", "DETACH", "ALTER", "REPLACE",
|
||||
}
|
||||
|
||||
func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
// see https://github.com/mattn/go-sqlite3/issues/950
|
||||
return nil, errors.New("empty query")
|
||||
}
|
||||
|
||||
var isPossibleWriteQuery bool
|
||||
|
||||
// loosely check the query type
|
||||
ucQuery := strings.ToUpper(query)
|
||||
if !strings.HasPrefix(ucQuery, "SELECT") {
|
||||
for _, prefix := range knownWriteQueryPrefixes {
|
||||
if strings.HasPrefix(ucQuery, prefix) {
|
||||
isPossibleWriteQuery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't extend the request context to minimize the risk of
|
||||
// causing integrity issues with custom non-transaction mutations
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
|
||||
defer cancelFunc()
|
||||
|
||||
result := &runSQLResult{
|
||||
// init empty slices to ensure "[]" serialization
|
||||
Columns: []runSQLResultColumn{},
|
||||
Rows: [][]any{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
defer func() {
|
||||
result.ExecTime = time.Since(now).Milliseconds()
|
||||
}()
|
||||
|
||||
// assume write/mutation query
|
||||
// ---------------------------------------------------------------
|
||||
if isPossibleWriteQuery {
|
||||
// auto wrap in transaction in case there are multiple inline queries
|
||||
txErr := app.RunInTransaction(func(txApp core.App) error {
|
||||
execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.AffectedRows, err = execResult.RowsAffected()
|
||||
if err != nil {
|
||||
// non-critical error (e.g. not supported by the driver)
|
||||
txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
return nil, txErr
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// assume query returning rows
|
||||
// ---------------------------------------------------------------
|
||||
rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// populate columns info
|
||||
// ---
|
||||
colTypes, err := rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, colType := range colTypes {
|
||||
col := runSQLResultColumn{
|
||||
Name: colType.Name(),
|
||||
Type: colType.DatabaseTypeName(),
|
||||
}
|
||||
col.Nullable, _ = colType.Nullable()
|
||||
|
||||
result.Columns = append(result.Columns, col)
|
||||
}
|
||||
|
||||
// populate rows
|
||||
// ---
|
||||
for rows.Next() {
|
||||
if len(result.Rows) >= maxRows {
|
||||
break
|
||||
}
|
||||
|
||||
rowData := make([]any, len(colTypes))
|
||||
for i := 0; i < len(colTypes); i++ {
|
||||
var v *string
|
||||
rowData[i] = &v
|
||||
}
|
||||
|
||||
err := rows.Scan(rowData...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Rows = append(result.Rows, rowData)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestSQLRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
Headers: map[string]string{
|
||||
// users, test2@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[{"name":"1","type":"","nullable":true}]`,
|
||||
`"rows":[["1"]]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "empty query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":""}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"query":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "invalid query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"invalid"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
`SQL logic error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "query with length above the limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"query":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "query with length equal to the limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select '` + strings.Repeat("a", 4985) + `' as id"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[{"name":"id","type":"","nullable":true}]`,
|
||||
`"rows":[["aaa`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "single write query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key)"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if !app.HasTable("test_sql_table") {
|
||||
t.Fatalf("Missing expected new %q table", "test_sql_table")
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[]`,
|
||||
`"rows":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple write queries",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1)"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
var total int
|
||||
err := app.DB().NewQuery("select count(*) from test_sql_table").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total != 1 {
|
||||
t.Fatalf("Expected exactly 1 row, found: %d", total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":1`,
|
||||
`"columns":[]`,
|
||||
`"rows":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple write queries (transaction rollback)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1);invalid"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.HasTable("test_sql_table") {
|
||||
t.Fatalf("Expected table %q to not be created", "test_sql_table")
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
`SQL logic error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple read queries",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1;select 2"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
// only the result of the last query should be returned
|
||||
`"columns":[{"name":"2","type":"","nullable":true}]`,
|
||||
`"rows":[["2"]]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
+28
-2
@@ -54,11 +54,16 @@ type App interface {
|
||||
// Bootstrap initializes the application
|
||||
// (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
|
||||
|
||||
// ResetBootstrapState releases the initialized core app resources
|
||||
// ClearBootstrap releases the initialized core app resources
|
||||
// (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
|
||||
|
||||
// DataDir returns the app data directory path.
|
||||
@@ -713,6 +718,13 @@ type App interface {
|
||||
// resources (db, app settings, etc).
|
||||
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
|
||||
// (after starting the TCP listener but before initializing the blocking serve task),
|
||||
// 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.
|
||||
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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
+137
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"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/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
|
||||
// the current running application process.
|
||||
//
|
||||
@@ -299,111 +194,3 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
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()),
|
||||
)
|
||||
}
|
||||
|
||||
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{
|
||||
"storage",
|
||||
"data.db",
|
||||
"data.db-shm",
|
||||
"data.db-wal",
|
||||
"auxiliary.db",
|
||||
"auxiliary.db-shm",
|
||||
"auxiliary.db-wal",
|
||||
".gitignore",
|
||||
}
|
||||
|
||||
+190
-73
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -44,6 +45,9 @@ const (
|
||||
|
||||
// @todo consider removing after backups refactoring
|
||||
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.
|
||||
@@ -86,11 +90,12 @@ type BaseApp struct {
|
||||
auxNonconcurrentDB dbx.Builder
|
||||
|
||||
// app event hooks
|
||||
onBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onServe *hook.Hook[*ServeEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
onBackupCreate *hook.Hook[*BackupEvent]
|
||||
onBackupRestore *hook.Hook[*BackupEvent]
|
||||
onBootstrap *hook.Hook[*BootstrapEvent]
|
||||
onBootstrapClear *hook.Hook[*BootstrapEvent]
|
||||
onServe *hook.Hook[*ServeEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
onBackupCreate *hook.Hook[*BackupEvent]
|
||||
onBackupRestore *hook.Hook[*BackupEvent]
|
||||
|
||||
// db model hooks
|
||||
onModelValidate *hook.Hook[*ModelEvent]
|
||||
@@ -146,6 +151,17 @@ type BaseApp struct {
|
||||
onMailerRecordOTPSend *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
|
||||
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectRequestEvent]
|
||||
onRealtimeMessageSend *hook.Hook[*RealtimeMessageEvent]
|
||||
@@ -235,6 +251,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
func (app *BaseApp) initHooks() {
|
||||
// app event hooks
|
||||
app.onBootstrap = &hook.Hook[*BootstrapEvent]{}
|
||||
app.onBootstrapClear = &hook.Hook[*BootstrapEvent]{}
|
||||
app.onServe = &hook.Hook[*ServeEvent]{}
|
||||
app.onTerminate = &hook.Hook[*TerminateEvent]{}
|
||||
app.onBackupCreate = &hook.Hook[*BackupEvent]{}
|
||||
@@ -294,6 +311,10 @@ func (app *BaseApp) initHooks() {
|
||||
app.onMailerRecordOTPSend = &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
|
||||
app.onRealtimeConnectRequest = &hook.Hook[*RealtimeConnectRequestEvent]{}
|
||||
app.onRealtimeMessageSend = &hook.Hook[*RealtimeMessageEvent]{}
|
||||
@@ -387,14 +408,14 @@ func (app *BaseApp) IsBootstrapped() bool {
|
||||
// Bootstrap initializes the application
|
||||
// (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 {
|
||||
event := &BootstrapEvent{}
|
||||
event.App = app
|
||||
|
||||
err := app.OnBootstrap().Trigger(event, func(e *BootstrapEvent) error {
|
||||
// clear resources of previous core state (if any)
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
// clear previous bootstrap state (if any)
|
||||
if err := app.ClearBootstrap(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -442,41 +463,55 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
type closer interface {
|
||||
Close() error
|
||||
// Deprecated: use [ClearBootstrap].
|
||||
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.).
|
||||
func (app *BaseApp) ResetBootstrapState() error {
|
||||
app.Cron().Stop()
|
||||
|
||||
var errs []error
|
||||
|
||||
dbs := []*dbx.Builder{
|
||||
&app.concurrentDB,
|
||||
&app.nonconcurrentDB,
|
||||
&app.auxConcurrentDB,
|
||||
&app.auxNonconcurrentDB,
|
||||
//
|
||||
// This method is no-op if the application is not bootstrapped yet.
|
||||
func (app *BaseApp) ClearBootstrap() error {
|
||||
if !app.IsBootstrapped() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, db := range dbs {
|
||||
if db == nil {
|
||||
continue
|
||||
event := &BootstrapEvent{}
|
||||
event.App = app
|
||||
|
||||
return app.OnBootstrapClear().Trigger(event, func(e *BootstrapEvent) error {
|
||||
type closer interface {
|
||||
Close() error
|
||||
}
|
||||
if v, ok := (*db).(closer); ok {
|
||||
if err := v.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
|
||||
var errs []error
|
||||
|
||||
dbs := []*dbx.Builder{
|
||||
&app.concurrentDB,
|
||||
&app.nonconcurrentDB,
|
||||
&app.auxConcurrentDB,
|
||||
&app.auxNonconcurrentDB,
|
||||
}
|
||||
|
||||
for _, db := range dbs {
|
||||
if db == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := (*db).(closer); ok {
|
||||
if err := v.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
*db = nil
|
||||
}
|
||||
*db = nil
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
return filesystem.NewS3(
|
||||
// S3
|
||||
fsys, err = filesystem.NewS3(
|
||||
app.settings.S3.Bucket,
|
||||
app.settings.S3.Region,
|
||||
app.settings.S3.Endpoint,
|
||||
@@ -722,10 +758,41 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
app.settings.S3.Secret,
|
||||
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
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
// attach delete hook
|
||||
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
|
||||
@@ -767,7 +834,7 @@ func (app *BaseApp) Restart() error {
|
||||
event.IsRestart = true
|
||||
|
||||
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
|
||||
defer func() {
|
||||
@@ -810,6 +877,10 @@ func (app *BaseApp) OnBootstrap() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBootstrap
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnBootstrapClear() *hook.Hook[*BootstrapEvent] {
|
||||
return app.onBootstrapClear
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnServe() *hook.Hook[*ServeEvent] {
|
||||
return app.onServe
|
||||
}
|
||||
@@ -1016,6 +1087,18 @@ func (app *BaseApp) OnMailerRecordAuthAlertSend(tags ...string) *hook.TaggedHook
|
||||
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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -1173,7 +1256,7 @@ func (app *BaseApp) OnBatchRequest() *hook.Hook[*BatchRequestEvent] {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) initDataDB() error {
|
||||
dbPath := filepath.Join(app.DataDir(), "data.db")
|
||||
dbPath := filepath.Join(app.DataDir(), dataDBFilename)
|
||||
|
||||
concurrentDB, err := app.config.DBConnect(dbPath)
|
||||
if err != nil {
|
||||
@@ -1235,7 +1318,7 @@ func normalizeSQLLog(sql string) string {
|
||||
func (app *BaseApp) initAuxDB() error {
|
||||
// note: renamed to "auxiliary" because "aux" is a reserved Windows filename
|
||||
// (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)
|
||||
if err != nil {
|
||||
@@ -1351,7 +1434,15 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
Id: "__pbCronStart__",
|
||||
Func: func(e *ServeEvent) error {
|
||||
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()
|
||||
},
|
||||
Priority: 999,
|
||||
@@ -1408,9 +1499,41 @@ func getLoggerMinLevel(app App) slog.Level {
|
||||
}
|
||||
|
||||
func (app *BaseApp) initLogger() error {
|
||||
var stopped atomic.Bool
|
||||
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool, 1)
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
runLogsWrite := func(logs []*logger.Log) {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
//
|
||||
// note: based on several local tests there is no
|
||||
// significant performance difference between small number
|
||||
// of separate write queries vs 1 big INSERT
|
||||
app.AuxRunInTransaction(func(txApp App) error {
|
||||
model := &Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
model.Id = GenerateDefaultRandomId()
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
|
||||
if err := txApp.AuxSave(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: getLoggerMinLevel(app),
|
||||
@@ -1425,40 +1548,30 @@ func (app *BaseApp) initLogger() error {
|
||||
}
|
||||
}
|
||||
|
||||
ticker.Reset(duration)
|
||||
if !stopped.Load() {
|
||||
ticker.Reset(duration)
|
||||
}
|
||||
|
||||
return app.Settings().Logs.MaxDays > 0
|
||||
},
|
||||
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return nil
|
||||
// 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) })
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
|
||||
app.AuxRunInTransaction(func(txApp App) error {
|
||||
model := &Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
model.Id = GenerateDefaultRandomId()
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
|
||||
if err := txApp.AuxSave(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
go func() {
|
||||
routine.FireAndForget(func() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
@@ -1469,21 +1582,25 @@ func (app *BaseApp) initLogger() error {
|
||||
handler.WriteAll(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
app.logger = slog.New(handler)
|
||||
|
||||
// write all remaining logs before ticker.Stop to avoid races with ResetBootstrap user calls
|
||||
app.OnTerminate().Bind(&hook.Handler[*TerminateEvent]{
|
||||
Id: "__pbAppLoggerOnTerminate__",
|
||||
Func: func(e *TerminateEvent) error {
|
||||
handler.WriteAll(context.Background())
|
||||
// attempt to write all queued logs before clearing the application bootstrap state
|
||||
app.OnBootstrapClear().Bind(&hook.Handler[*BootstrapEvent]{
|
||||
Id: "__pbAppLoggerFlushBeforeStop__",
|
||||
Func: func(e *BootstrapEvent) error {
|
||||
// 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()
|
||||
|
||||
// don't block in case OnTerminate is triggered more than once
|
||||
// don't block in case the hook is triggered more than once
|
||||
select {
|
||||
case done <- true:
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
|
||||
+153
-48
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
_ "unsafe"
|
||||
@@ -64,7 +65,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if app.IsBootstrapped() {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
@@ -114,7 +115,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
runNilChecks(nilChecksBeforeReset)
|
||||
|
||||
// reset
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
if err := app.ClearBootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ func TestNewBaseAppTx(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -184,7 +185,7 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
m1, ok := client1.(*mailer.Sendmail)
|
||||
@@ -214,7 +215,7 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
@@ -243,7 +244,7 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
app := core.NewBaseApp(core.BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
@@ -265,68 +266,172 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const logsThreshold = 200
|
||||
|
||||
func assertLogsCount(t *testing.T, app core.App, expected int) {
|
||||
var total int
|
||||
|
||||
err := app.LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
|
||||
for i := 0; i < logsThreshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
// short delay for the non-blocking write goroutine
|
||||
synctest.Sleep(time.Nanosecond)
|
||||
|
||||
assertLogsCount(t, app, 0)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("test batch logs writes", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 2
|
||||
|
||||
for i := 0; i < logsThreshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
// short delay for the non-blocking write goroutine
|
||||
synctest.Sleep(time.Nanosecond)
|
||||
|
||||
// below threshold
|
||||
assertLogsCount(t, app, 0)
|
||||
|
||||
// threshold reached -> should trigger batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be skipped from this batch and added for the next
|
||||
app.Logger().Error("test")
|
||||
|
||||
// short delay for the non-blocking write goroutine
|
||||
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()
|
||||
|
||||
// reset
|
||||
if err := app.DeleteOldLogs(time.Now()); err != nil {
|
||||
// clear old logs
|
||||
err := app.DeleteOldLogs(time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const logsThreshold = 200
|
||||
|
||||
totalLogs := func(app core.App, t *testing.T) int {
|
||||
var total int
|
||||
|
||||
err := app.LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
return total
|
||||
// enable logs persistence
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
err = app.Save(app.Settings())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("disabled logs retention", func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
t.Run("flush on timer tick", func(t *testing.T) {
|
||||
timeout := time.After(5 * time.Second)
|
||||
done := make(chan struct{})
|
||||
|
||||
for i := 0; i < logsThreshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
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:
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
assertLogsCount(t, app, 1)
|
||||
})
|
||||
|
||||
t.Run("test batch logs writes", func(t *testing.T) {
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
|
||||
for i := 0; i < logsThreshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
|
||||
// should trigger batch write
|
||||
t.Run("before ClearBootstrap flush", func(t *testing.T) {
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be added for the next batch write
|
||||
app.Logger().Error("test")
|
||||
app.Bootstrap()
|
||||
|
||||
if total := totalLogs(app, t); total != logsThreshold {
|
||||
t.Fatalf("Expected %d logs, got %d", logsThreshold, total)
|
||||
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:
|
||||
}
|
||||
|
||||
// wait for ~3 secs to check the timer trigger
|
||||
time.Sleep(3200 * time.Millisecond)
|
||||
if total := totalLogs(app, t); total != logsThreshold+1 {
|
||||
t.Fatalf("Expected %d logs, got %d", logsThreshold+1, total)
|
||||
}
|
||||
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,
|
||||
IsDev: s.isDev,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
defer app.ClearBootstrap()
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -186,7 +187,11 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
|
||||
)
|
||||
if err := validator.run(); err != nil {
|
||||
// serialize the validation error(s)
|
||||
serializedErr, _ := json.MarshalIndent(err, "", " ")
|
||||
serializedErr, _ := json.Marshal(
|
||||
err,
|
||||
jsontext.WithIndentPrefix(""),
|
||||
jsontext.WithIndent(" "),
|
||||
)
|
||||
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
"validation_collections_import_failure",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -449,7 +449,7 @@ func TestImportCollectionsCreateRules(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(collection)
|
||||
raw, err := json.Marshal(collection, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+38
-11
@@ -1,7 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -348,6 +348,7 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
}
|
||||
|
||||
// @todo experiment eventually replacing the rules *string with a struct?
|
||||
// @todo consider changing the Indexes field to a "getter" for the sqlite_master table?
|
||||
type baseCollection struct {
|
||||
BaseModel
|
||||
|
||||
@@ -521,8 +522,6 @@ func (m *Collection) unmarshalRawOptions() error {
|
||||
// For new/"blank" Collection models it replaces the model with a factory
|
||||
// instance and then unmarshal the provided data one on top of it.
|
||||
func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
type alias *Collection
|
||||
|
||||
// initialize the default fields
|
||||
// (e.g. in case the collection was NOT created using the designated factories)
|
||||
if m.IsNew() && m.Type == "" {
|
||||
@@ -539,7 +538,8 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
*m = *blank
|
||||
}
|
||||
|
||||
return json.Unmarshal(b, alias(m))
|
||||
type alias Collection
|
||||
return json.Unmarshal(b, (*alias)(m))
|
||||
}
|
||||
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
@@ -549,10 +549,12 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
switch m.Type {
|
||||
case CollectionTypeView:
|
||||
return json.Marshal(struct {
|
||||
alias := struct {
|
||||
baseCollection
|
||||
collectionViewOptions
|
||||
}{m.baseCollection, m.collectionViewOptions})
|
||||
}{m.baseCollection, m.collectionViewOptions}
|
||||
|
||||
return json.Marshal(alias, json.Deterministic(true))
|
||||
case CollectionTypeAuth:
|
||||
alias := struct {
|
||||
baseCollection
|
||||
@@ -581,15 +583,15 @@ func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
alias.OAuth2.Providers = redactedProviders
|
||||
}
|
||||
|
||||
return json.Marshal(alias)
|
||||
return json.Marshal(alias, json.Deterministic(true))
|
||||
default:
|
||||
return json.Marshal(m.baseCollection)
|
||||
return json.Marshal(m.baseCollection, json.Deterministic(true))
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a string representation of the current collection.
|
||||
func (m Collection) String() string {
|
||||
raw, _ := json.Marshal(m)
|
||||
raw, _ := m.MarshalJSON()
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
@@ -820,6 +822,25 @@ func onCollectionSave(e *CollectionEvent) error {
|
||||
|
||||
e.Collection.updateGeneratedIdIfExists(e.App)
|
||||
|
||||
// normalize indexes table name
|
||||
for i, raw := range e.Collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
// no need to normalize
|
||||
if parsed.TableName == e.Collection.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed.TableName = e.Collection.Name
|
||||
|
||||
normalized := parsed.Build()
|
||||
if normalized == "" {
|
||||
continue // leave to the model validator to decide whether to return an error
|
||||
}
|
||||
|
||||
e.Collection.Indexes[i] = normalized
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
@@ -905,8 +926,14 @@ func onCollectionSaveExecute(e *CollectionEvent) error {
|
||||
}
|
||||
|
||||
// trigger an update for all views with changed fields as a result of the current collection save
|
||||
// (ignoring view errors to allow users to update the query from the UI)
|
||||
resaveViewsWithChangedFields(e.App, e.Collection.Id)
|
||||
// (only log the error to allow users to adjust the problematic view queries from the UI)
|
||||
depViewsErr := resaveViewsWithChangedFields(e.App, e.Collection.Id)
|
||||
if depViewsErr != nil {
|
||||
e.App.Logger().Warn(
|
||||
"Dependent view collection(s) may need to be updated after "+e.Collection.Name+" collection change",
|
||||
"error", depViewsErr,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -70,7 +73,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
AuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 604800, // 7 days
|
||||
Duration: 432000, // 5days
|
||||
},
|
||||
PasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -82,7 +85,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
VerificationToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 259200, // 3days
|
||||
Duration: 86400, // 1day
|
||||
},
|
||||
FileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -409,6 +412,71 @@ type OAuth2Config struct {
|
||||
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.
|
||||
//
|
||||
// Returns false and zero config if no such provider is available in c.Providers.
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -1016,8 +1086,8 @@ func TestOAuth2ProviderConfigInitProvider(t *testing.T) {
|
||||
t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
|
||||
}
|
||||
|
||||
rawMeta, _ := json.Marshal(provider.Extra())
|
||||
expectedMeta, _ := json.Marshal(s.expectedConfig.Extra)
|
||||
rawMeta, _ := json.Marshal(provider.Extra(), json.Deterministic(true))
|
||||
expectedMeta, _ := json.Marshal(s.expectedConfig.Extra, json.Deterministic(true))
|
||||
if !bytes.Equal(rawMeta, expectedMeta) {
|
||||
t.Fatalf("Expected PKCE %v, got %v", *s.expectedConfig.PKCE, provider.PKCE())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var defaultVerificationTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Verify</a>
|
||||
</p>
|
||||
<p><i>If you didn't recently register, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
@@ -31,7 +32,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Reset password</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>
|
||||
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
@@ -45,7 +46,7 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Confirm new email</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>
|
||||
<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -618,7 +618,7 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rawResult, err := json.Marshal(collection)
|
||||
rawResult, err := json.Marshal(collection, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -829,7 +829,7 @@ func TestCollectionDBExport(t *testing.T) {
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(result)
|
||||
raw, err := json.Marshal(result, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1678,3 +1678,40 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionSaveIndexesTableNameNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
dummyCollection := core.NewBaseCollection("new_test")
|
||||
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
|
||||
dummyCollection.Indexes = []string{
|
||||
"create index `new_test_idx1` on `` (`test`) where 1=1",
|
||||
"create index `new_test_idx2` on `test` (`test`) where 1=2",
|
||||
"create index `new_test_idx3` on `someting_else` (`test`) where 1=3",
|
||||
}
|
||||
|
||||
err := app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// refetch a clean state
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(dummyCollection.Indexes) != 3 {
|
||||
t.Fatalf("Expected 3 indexes, got %v", dummyCollection.Indexes)
|
||||
}
|
||||
|
||||
for _, raw := range dummyCollection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
if parsed.TableName != dummyCollection.Name {
|
||||
t.Fatalf("Expected all indexes to have tableName %q, found %q:\n%s", dummyCollection.Name, parsed.TableName, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
)
|
||||
|
||||
var _ optionsValidator = (*collectionViewOptions)(nil)
|
||||
|
||||
+45
-82
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -250,52 +250,6 @@ func (app *BaseApp) TruncateCollection(collection *Collection) error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// saveViewCollection persists the provided View collection changes:
|
||||
// - deletes the old related SQL view (if any)
|
||||
// - creates a new SQL view with the latest newCollection.Options.Query
|
||||
// - generates new feilds list based on newCollection.Options.Query
|
||||
// - updates newCollection.Fields based on the generated view table info and query
|
||||
// - saves the newCollection
|
||||
//
|
||||
// This method returns an error if newCollection is not a "view".
|
||||
func saveViewCollection(app App, newCollection, oldCollection *Collection) error {
|
||||
if !newCollection.IsView() {
|
||||
return errors.New("not a view collection")
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
query := newCollection.ViewQuery
|
||||
|
||||
// generate collection fields from the query
|
||||
viewFields, err := txApp.CreateViewFields(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old renamed view
|
||||
if oldCollection != nil {
|
||||
if err := txApp.DeleteView(oldCollection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wrap view query if necessary
|
||||
query, err = normalizeViewQueryId(txApp, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize view query id: %w", err)
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
if err := txApp.SaveView(newCollection.Name, query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newCollection.Fields = viewFields
|
||||
|
||||
return txApp.Save(newCollection)
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeViewQueryId wraps (if necessary) the provided view query
|
||||
// with a subselect to ensure that the id column is a text since
|
||||
// currently we don't support non-string model ids
|
||||
@@ -342,50 +296,59 @@ func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
var collectionErrors []error
|
||||
|
||||
for _, collection := range collections {
|
||||
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
|
||||
continue
|
||||
}
|
||||
|
||||
// clone the existing fields for temp modifications
|
||||
oldFields, err := collection.Fields.Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
check := func() error {
|
||||
// clone the existing fields for temp modifications
|
||||
oldFields, err := collection.Fields.Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate new fields from the query
|
||||
newFields, err := txApp.CreateViewFields(collection.ViewQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unset the fields' ids to exclude from the comparison
|
||||
for _, f := range oldFields {
|
||||
f.SetId("")
|
||||
}
|
||||
for _, f := range newFields {
|
||||
f.SetId("")
|
||||
}
|
||||
|
||||
encodedNewFields, err := json.Marshal(newFields, json.Deterministic(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encodedOldFields, err := json.Marshal(oldFields, json.Deterministic(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes.EqualFold(encodedNewFields, encodedOldFields) {
|
||||
return nil // no changes
|
||||
}
|
||||
|
||||
return txApp.Save(collection)
|
||||
}
|
||||
|
||||
// generate new fields from the query
|
||||
newFields, err := txApp.CreateViewFields(collection.ViewQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unset the fields' ids to exclude from the comparison
|
||||
for _, f := range oldFields {
|
||||
f.SetId("")
|
||||
}
|
||||
for _, f := range newFields {
|
||||
f.SetId("")
|
||||
}
|
||||
|
||||
encodedNewFields, err := json.Marshal(newFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encodedOldFields, err := json.Marshal(oldFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes.EqualFold(encodedNewFields, encodedOldFields) {
|
||||
continue // no changes
|
||||
}
|
||||
|
||||
if err := saveViewCollection(txApp, collection, nil); err != nil {
|
||||
return err
|
||||
if err := check(); err != nil {
|
||||
collectionErrors = append(
|
||||
collectionErrors,
|
||||
fmt.Errorf("[%s] %w", collection.Name, err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(collectionErrors...)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
@@ -306,8 +306,10 @@ func dropCollectionIndexes(app App, collection *Collection) error {
|
||||
for _, raw := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
continue
|
||||
// note: don't check IsValid because the index table name may not be populated
|
||||
// (https://github.com/pocketbase/pocketbase/issues/7689)
|
||||
if parsed.IndexName == "" {
|
||||
return fmt.Errorf("failed to dop index - missing index name: %s", raw)
|
||||
}
|
||||
|
||||
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -278,12 +278,12 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
t.Fatalf("Failed to load record: %v", err)
|
||||
}
|
||||
|
||||
encodedResult, err := json.Marshal(result)
|
||||
encodedResult, err := json.Marshal(result, json.Deterministic(true))
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("Failed to encode expectation: %v", err)
|
||||
}
|
||||
@@ -294,3 +294,49 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropIndexWithoutTableName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
properIndex := "CREATE INDEX `new_test_idx2` ON `new_test` (`test`)"
|
||||
indexWithoutTableName := "CREATE INDEX `new_test_idx2` ON `` (`test`)"
|
||||
|
||||
dummyCollection := core.NewBaseCollection("new_test")
|
||||
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
|
||||
dummyCollection.Indexes = []string{properIndex}
|
||||
|
||||
err := app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave without table name but without hooks to avoid the normalizations
|
||||
dummyCollection.Indexes[0] = indexWithoutTableName
|
||||
err = app.UnsafeWithoutHooks().Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave should normalize the index
|
||||
err = app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(dummyCollection.Indexes) != 1 || dummyCollection.Indexes[0] != properIndex {
|
||||
t.Fatalf("Expected exactly 1 index\n%s\ngot\n%v", properIndex, dummyCollection.Indexes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
|
||||
+1
-1
@@ -10,8 +10,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
|
||||
// Note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection.
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ package core_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -120,7 +120,11 @@ func TestTableInfo(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.tableName), func(t *testing.T) {
|
||||
rows, _ := app.TableInfo(s.tableName)
|
||||
|
||||
raw, err := json.Marshal(rows)
|
||||
raw, err := json.Marshal(
|
||||
rows,
|
||||
json.Deterministic(true),
|
||||
json.FormatNilSliceAsNull(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -183,7 +183,7 @@ func TestRequestEventRequestInfo(t *testing.T) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(info)
|
||||
raw, err := json.Marshal(info, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to serialize request info: %v", err)
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func TestRequestInfoClone(t *testing.T) {
|
||||
|
||||
// check the original data
|
||||
// ---
|
||||
originalRaw, err := json.Marshal(info)
|
||||
originalRaw, err := json.Marshal(info, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to serialize original request info: %v", err)
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func TestRequestInfoClone(t *testing.T) {
|
||||
|
||||
// check the clone data
|
||||
// ---
|
||||
cloneRaw, err := json.Marshal(clone)
|
||||
cloneRaw, err := json.Marshal(clone, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to serialize clone request info: %v", err)
|
||||
}
|
||||
|
||||
+36
-1
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
@@ -188,6 +189,24 @@ type MailerRecordEvent struct {
|
||||
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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -448,8 +467,24 @@ type RealtimeConnectRequestEvent struct {
|
||||
|
||||
Client subscriptions.Client
|
||||
|
||||
// note: modifying it after the connect has no effect
|
||||
// IdleTimeout specifies the max duration to wait for a new message
|
||||
// before closing the connection.
|
||||
//
|
||||
// Modifying the value after the connection has been established has no effect.
|
||||
//
|
||||
// Defaults to 5 minutes.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// MaxTimeout specifies the maximum duration a realtime connection
|
||||
// can remain open (including even if there are ongoing messages).
|
||||
//
|
||||
// Once the specified duration expires, the current connection will
|
||||
// be terminated, until a client reconnect is issued (if the client is still active).
|
||||
//
|
||||
// Modifying the value after the connection has been established has no effect.
|
||||
//
|
||||
// Defaults to 30 minutes.
|
||||
MaxTimeout time.Duration
|
||||
}
|
||||
|
||||
type RealtimeMessageEvent struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
+7
-3
@@ -9,7 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
@@ -732,12 +732,16 @@ func (f *FileField) toSliceValue(raw any) []any {
|
||||
case nil:
|
||||
// nothing to cast
|
||||
case *filesystem.File:
|
||||
result = append(result, value)
|
||||
if value != nil {
|
||||
result = append(result, value)
|
||||
}
|
||||
case filesystem.File:
|
||||
result = append(result, &value)
|
||||
case []*filesystem.File:
|
||||
for _, v := range value {
|
||||
result = append(result, v)
|
||||
if v != nil {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
case []filesystem.File:
|
||||
for _, v := range value {
|
||||
|
||||
+22
-18
@@ -3,7 +3,7 @@ package core_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -98,11 +98,13 @@ func TestFileFieldPrepareValue(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1Raw, err := json.Marshal(f1)
|
||||
f1Raw, err := json.Marshal(f1, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var nilFile *filesystem.File
|
||||
|
||||
scenarios := []struct {
|
||||
raw any
|
||||
field *core.FileField
|
||||
@@ -114,8 +116,9 @@ func TestFileFieldPrepareValue(t *testing.T) {
|
||||
{123, &core.FileField{MaxSelect: 1}, `"123"`},
|
||||
{"a", &core.FileField{MaxSelect: 1}, `"a"`},
|
||||
{`["a"]`, &core.FileField{MaxSelect: 1}, `"a"`},
|
||||
{*f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
|
||||
{f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
|
||||
{*f1, &core.FileField{MaxSelect: 1}, string(f1Raw)},
|
||||
{nilFile, &core.FileField{MaxSelect: 1}, `""`},
|
||||
{[]string{}, &core.FileField{MaxSelect: 1}, `""`},
|
||||
{[]string{"a", "b"}, &core.FileField{MaxSelect: 1}, `"b"`},
|
||||
|
||||
@@ -126,8 +129,9 @@ func TestFileFieldPrepareValue(t *testing.T) {
|
||||
{"a", &core.FileField{MaxSelect: 2}, `["a"]`},
|
||||
{`["a"]`, &core.FileField{MaxSelect: 2}, `["a"]`},
|
||||
{[]any{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
|
||||
{[]*filesystem.File{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
|
||||
{[]filesystem.File{*f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
|
||||
{[]*filesystem.File{f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
|
||||
{[]any{nilFile, f1}, &core.FileField{MaxSelect: 2}, `[` + string(f1Raw) + `]`},
|
||||
{[]string{}, &core.FileField{MaxSelect: 2}, `[]`},
|
||||
{[]string{"a", "b", "c"}, &core.FileField{MaxSelect: 2}, `["a","b","c"]`},
|
||||
}
|
||||
@@ -139,7 +143,7 @@ func TestFileFieldPrepareValue(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vRaw, err := json.Marshal(v)
|
||||
vRaw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -208,7 +212,7 @@ func TestFileFieldDriverValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
vRaw, err := json.Marshal(v)
|
||||
vRaw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -695,7 +699,7 @@ func TestFileFieldFindGetter(t *testing.T) {
|
||||
|
||||
v := getter(record)
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
raw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -812,7 +816,7 @@ func TestFileFieldFindSetter(t *testing.T) {
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -911,8 +915,8 @@ func TestFileFieldIntercept(t *testing.T) {
|
||||
|
||||
tests.TestValidationErrors(t, err, []string{"text"})
|
||||
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3})
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3}, json.Deterministic(true))
|
||||
if !bytes.Equal(expectedRaw, raw) {
|
||||
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
|
||||
}
|
||||
@@ -931,8 +935,8 @@ func TestFileFieldIntercept(t *testing.T) {
|
||||
t.Fatalf("Expected save to succeed, got %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name})
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
|
||||
if !bytes.Equal(expectedRaw, raw) {
|
||||
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
|
||||
}
|
||||
@@ -952,8 +956,8 @@ func TestFileFieldIntercept(t *testing.T) {
|
||||
t.Fatalf("Expected save to succeed, got %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"))
|
||||
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name})
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
|
||||
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
|
||||
if !bytes.Equal(expectedRaw, raw) {
|
||||
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
|
||||
}
|
||||
@@ -1063,8 +1067,8 @@ func TestFileFieldInterceptTx(t *testing.T) {
|
||||
t.Fatalf("Expected save to succeed, got %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name})
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
|
||||
expectedRaw, _ := json.Marshal([]any{f1.Name, f3.Name}, json.Deterministic(true))
|
||||
if !bytes.Equal(expectedRaw, raw) {
|
||||
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
|
||||
}
|
||||
@@ -1086,8 +1090,8 @@ func TestFileFieldInterceptTx(t *testing.T) {
|
||||
t.Fatalf("Expected save to succeed, got %v", err)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"))
|
||||
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name})
|
||||
raw, _ := json.Marshal(record.GetRaw("file_many"), json.Deterministic(true))
|
||||
expectedRaw, _ := json.Marshal([]any{f3.Name, f4.Name}, json.Deterministic(true))
|
||||
if !bytes.Equal(expectedRaw, raw) {
|
||||
t.Fatalf("Expected file field value\n%s\ngot\n%s", expectedRaw, raw)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestGeoPointFieldPrepareValue(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
raw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+16
-2
@@ -2,12 +2,14 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json/jsontext"
|
||||
"encoding/json/v2"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
if f.Required && slices.Contains(emptyJSONValues, rawStr) {
|
||||
|
||||
@@ -101,6 +101,16 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
},
|
||||
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)",
|
||||
&core.JSONField{Name: "test"},
|
||||
|
||||
+19
-7
@@ -2,10 +2,9 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -23,6 +22,12 @@ var (
|
||||
_ SetterFinder = (*NumberField)(nil)
|
||||
)
|
||||
|
||||
var (
|
||||
onlyIntValidationError = validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed")
|
||||
minNumberValidationError = validation.NewError("validation_min_number_constraint", "Must be greater or equal than {{.min}}")
|
||||
maxNumberValidationError = validation.NewError("validation_max_number_constraint", "Must be less or equal than {{.max}}")
|
||||
)
|
||||
|
||||
// NumberField defines "number" type field for storing numeric (float64) value.
|
||||
//
|
||||
// The respective zero record field value is 0.
|
||||
@@ -151,15 +156,15 @@ func (f *NumberField) ValidateValue(ctx context.Context, app App, record *Record
|
||||
}
|
||||
|
||||
if f.OnlyInt && val != float64(int64(val)) {
|
||||
return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed")
|
||||
return onlyIntValidationError
|
||||
}
|
||||
|
||||
if f.Min != nil && val < *f.Min {
|
||||
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *f.Min))
|
||||
return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
|
||||
}
|
||||
|
||||
if f.Max != nil && val > *f.Max {
|
||||
return validation.NewError("validation_max_number_constraint", fmt.Sprintf("Must be less than %f", *f.Max))
|
||||
return maxNumberValidationError.SetParams(map[string]any{"max": *f.Max})
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -171,7 +176,14 @@ func (f *NumberField) ValidateSettings(ctx context.Context, app App, collection
|
||||
validation.By(f.checkOnlyInt),
|
||||
}
|
||||
if f.Min != nil && f.Max != nil {
|
||||
maxRules = append(maxRules, validation.Min(*f.Min))
|
||||
maxRules = append(maxRules, validation.By(func(value interface{}) error {
|
||||
// similar to validation.Min but doesn't ignore zero values
|
||||
v, _ := value.(*float64)
|
||||
if v == nil || f.Min == nil || *v >= *f.Min {
|
||||
return nil
|
||||
}
|
||||
return minNumberValidationError.SetParams(map[string]any{"min": *f.Min})
|
||||
}))
|
||||
}
|
||||
|
||||
return validation.ValidateStruct(f,
|
||||
@@ -190,7 +202,7 @@ func (f *NumberField) checkOnlyInt(value any) error {
|
||||
}
|
||||
|
||||
if *v != float64(int64(*v)) {
|
||||
return validation.NewError("validation_only_int_constraint", "Decimal numbers are not allowed.")
|
||||
return onlyIntValidationError
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"decumal min",
|
||||
"decimal min",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
@@ -248,7 +248,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"decumal min (onlyInt)",
|
||||
"decimal min (onlyInt)",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
@@ -272,7 +272,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"decumal max",
|
||||
"decimal max",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
@@ -283,7 +283,7 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"decumal max (onlyInt)",
|
||||
"decimal max (onlyInt)",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
@@ -307,19 +307,31 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"min > max",
|
||||
"min > max (0)",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: types.Pointer(2.0),
|
||||
Max: types.Pointer(1.0),
|
||||
Max: types.Pointer(0.0),
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"min <= max",
|
||||
"min (0) > max",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: types.Pointer(0.0),
|
||||
Max: types.Pointer(-1.0),
|
||||
}
|
||||
},
|
||||
[]string{"max"},
|
||||
},
|
||||
{
|
||||
"min == max",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
@@ -330,6 +342,18 @@ func TestNumberFieldValidateSettings(t *testing.T) {
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"min < max",
|
||||
func() *core.NumberField {
|
||||
return &core.NumberField{
|
||||
Id: "test",
|
||||
Name: "test",
|
||||
Min: types.Pointer(2.0),
|
||||
Max: types.Pointer(3.0),
|
||||
}
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -496,7 +496,7 @@ func TestPasswordFieldFindSetter(t *testing.T) {
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestRelationFieldPrepareValue(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vRaw, err := json.Marshal(v)
|
||||
vRaw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -590,7 +590,7 @@ func TestRelationFieldFindSetter(t *testing.T) {
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestSelectFieldPrepareValue(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vRaw, err := json.Marshal(v)
|
||||
vRaw, err := json.Marshal(v, json.Deterministic(true))
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -503,7 +503,7 @@ func TestSelectFieldFindSetter(t *testing.T) {
|
||||
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,12 +2,12 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
+1
-1
@@ -8,8 +8,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import (
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
validation "github.com/pocketbase/ozzo-validation/v4"
|
||||
"github.com/pocketbase/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -277,7 +277,7 @@ func (l *FieldsList) add(pos int, newField Field) {
|
||||
|
||||
// String returns the string representation of the current list.
|
||||
func (l FieldsList) String() string {
|
||||
v, _ := json.Marshal(l)
|
||||
v, _ := l.MarshalJSON()
|
||||
return string(v)
|
||||
}
|
||||
|
||||
@@ -355,12 +355,12 @@ func (l FieldsList) MarshalJSON() ([]byte, error) {
|
||||
wrapper = append(wrapper, data)
|
||||
}
|
||||
|
||||
return json.Marshal(wrapper)
|
||||
return json.Marshal(wrapper, json.Deterministic(true))
|
||||
}
|
||||
|
||||
// Value implements the [driver.Valuer] interface.
|
||||
func (l FieldsList) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(l)
|
||||
data, err := l.MarshalJSON()
|
||||
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package core_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/json/v2"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user