mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4116e3a7d | ||
|
|
64cee264f0 | ||
|
|
0ae9f24a81 | ||
|
|
6d942c7d30 | ||
|
|
9f67c5d563 | ||
|
|
1ac7330e0b | ||
|
|
461886f64e | ||
|
|
8d97eb0769 | ||
|
|
1208edec92 | ||
|
|
5555e63116 | ||
|
|
d6569b445c | ||
|
|
0b4f3b2adf | ||
|
|
8bd968ed06 | ||
|
|
5c961f8537 | ||
|
|
bf5eba0384 | ||
|
|
c213d9313e | ||
|
|
b31cf984a5 | ||
|
|
8671debc35 | ||
|
|
b0f027d27a | ||
|
|
98c8c98603 | ||
|
|
97345f0317 | ||
|
|
b29e404f22 | ||
|
|
d8ec36fa4c | ||
|
|
fb2eafe860 | ||
|
|
b9f391cf85 | ||
|
|
646f90ef43 | ||
|
|
5b6b4599b7 | ||
|
|
35fc6d0734 | ||
|
|
506b759560 | ||
|
|
d86e20b7f2 | ||
|
|
4c473385b2 | ||
|
|
afbbc1d97c | ||
|
|
4d3ba270c0 | ||
|
|
1bf7f148b0 | ||
|
|
6e6c873cc6 | ||
|
|
16da7d9e1a | ||
|
|
f7df737c45 | ||
|
|
64eefb44e8 | ||
|
|
31317df21c | ||
|
|
865865fdeb | ||
|
|
5b2575b754 | ||
|
|
6327ac20da | ||
|
|
8cd1c8709c | ||
|
|
14a2fd6215 | ||
|
|
cdfc1f7b70 | ||
|
|
41dcd9b4d4 | ||
|
|
0fb859c321 | ||
|
|
f57d38f529 | ||
|
|
04024cb6b7 | ||
|
|
58a2d3cd09 | ||
|
|
4d27278c60 | ||
|
|
70f1647a4c | ||
|
|
5b94aced3a | ||
|
|
070a1cd6d9 | ||
|
|
716f508d66 | ||
|
|
7013174315 | ||
|
|
559aad36a3 | ||
|
|
6416328c3b | ||
|
|
d3713a9d7c | ||
|
|
aaab643629 | ||
|
|
b283ee2263 | ||
|
|
995733000f | ||
|
|
99bdb4e701 | ||
|
|
3b79535dc7 | ||
|
|
05cc3f9e6c | ||
|
|
3f2e38ca82 | ||
|
|
531a7abec9 | ||
|
|
821aae4a62 | ||
|
|
ff5535f4de | ||
|
|
985ab1e5b7 | ||
|
|
69a805d0d1 | ||
|
|
d240649497 | ||
|
|
9957919d9a | ||
|
|
890a0904cf | ||
|
|
cdd32512d5 | ||
|
|
5835193900 | ||
|
|
4abe199acc | ||
|
|
a170923637 | ||
|
|
f4f3724b7a | ||
|
|
ba7cf8bf8e | ||
|
|
500615c1ee | ||
|
|
8961232a44 | ||
|
|
907167e696 | ||
|
|
4e51e393a2 | ||
|
|
5ea784609f | ||
|
|
ea5ca009de | ||
|
|
d13802133a | ||
|
|
f3a40001a4 | ||
|
|
1ae570921b | ||
|
|
f889a3fcb3 | ||
|
|
34fed679fd | ||
|
|
b7a49efa88 | ||
|
|
01e8c0f9f7 | ||
|
|
1d67a35acf | ||
|
|
e2d8028d0a | ||
|
|
d8a1875f84 | ||
|
|
79617e6d99 | ||
|
|
e6f1b3dfe4 | ||
|
|
94253f0dd5 | ||
|
|
6cfaf343ac | ||
|
|
9c562294ff | ||
|
|
3c5409d607 | ||
|
|
8868fa9ae6 | ||
|
|
c0fa53a2ab | ||
|
|
007b6a04ff | ||
|
|
731383a915 | ||
|
|
866d38caf9 | ||
|
|
1f6ab24b34 | ||
|
|
01e33c07fe | ||
|
|
69983bff5e | ||
|
|
2567659696 | ||
|
|
3e487f7e9d | ||
|
|
ca1a395628 | ||
|
|
1a47c70ccf | ||
|
|
1f4bdfb867 | ||
|
|
eae16cc42c | ||
|
|
1527b5ea4f | ||
|
|
ba6e17b3be | ||
|
|
b8219af941 | ||
|
|
8865cc1431 | ||
|
|
20b6ce4b84 | ||
|
|
e2f806d8bb | ||
|
|
49e3f4ad93 | ||
|
|
6d672348e7 | ||
|
|
80d774a8ef | ||
|
|
5a5125383a | ||
|
|
7fa1ff53c9 | ||
|
|
0f4e27a11f | ||
|
|
9997223923 | ||
|
|
632ade795f | ||
|
|
91bd739b71 | ||
|
|
957064d70b | ||
|
|
609792a355 | ||
|
|
2f5cfcfe87 | ||
|
|
5732bc38e3 | ||
|
|
d69181cfef | ||
|
|
8908d03b8c | ||
|
|
ebf73f5602 | ||
|
|
8416f03bcf | ||
|
|
5d87385170 | ||
|
|
837134559f | ||
|
|
fadd12cd22 | ||
|
|
469769d270 | ||
|
|
e4b7303a5d | ||
|
|
e1fb5d26a5 | ||
|
|
4f396ca439 | ||
|
|
ff08fc0fa4 | ||
|
|
4b511475ff | ||
|
|
2550a9de54 | ||
|
|
0f5dad7ede | ||
|
|
d0b1c9d998 | ||
|
|
fd9e120434 | ||
|
|
92731ddd50 | ||
|
|
4b4aaf2112 | ||
|
|
6013d14bc6 | ||
|
|
f3bcd7d3df | ||
|
|
71f9be3cb0 | ||
|
|
f605521208 | ||
|
|
4927583790 | ||
|
|
6e80cb8136 | ||
|
|
bb0a2dd698 | ||
|
|
2608efb56c | ||
|
|
eb2aa1cfc6 | ||
|
|
e1528aedac | ||
|
|
22b0a2b586 | ||
|
|
0ca86a0c87 | ||
|
|
b2c8f394af | ||
|
|
56b2641469 | ||
|
|
f266621a0f | ||
|
|
ca136c5dc1 | ||
|
|
abfe18bcce | ||
|
|
f3fc7f78d7 | ||
|
|
26fd069d11 | ||
|
|
62bde9e1f3 | ||
|
|
b945cd2fdf | ||
|
|
89a0520f7d | ||
|
|
a88b3c5db3 | ||
|
|
a37d9cfb84 | ||
|
|
5b084bbbfd | ||
|
|
322508f6d1 | ||
|
|
78e70bd52b | ||
|
|
58401459bf | ||
|
|
f172754775 | ||
|
|
31670ab3e1 | ||
|
|
baacf4913b | ||
|
|
8a94ccea42 | ||
|
|
b2bab9573a | ||
|
|
e5b5c1f76f | ||
|
|
ccb1c42220 | ||
|
|
a394777264 | ||
|
|
7d10b3c502 | ||
|
|
64ffb308bb | ||
|
|
916c74c218 | ||
|
|
17974d534e | ||
|
|
bde7a86b30 | ||
|
|
f7f8f09336 | ||
|
|
2a6b891a9b | ||
|
|
b4cb35483b | ||
|
|
1606cfd6e2 | ||
|
|
08f97ef0bb | ||
|
|
0dc263a40c | ||
|
|
824031e1a4 | ||
|
|
311bc74b7e | ||
|
|
4f3d1682de | ||
|
|
18728732b9 | ||
|
|
bb4f27cfb5 | ||
|
|
d423acad3b | ||
|
|
ef73052546 | ||
|
|
c89c68a4dc | ||
|
|
02495554cf | ||
|
|
cdbe6d78d3 | ||
|
|
ff6904f1f8 | ||
|
|
bc0222dcb4 | ||
|
|
04826ba588 | ||
|
|
6ca1f5c431 | ||
|
|
2863763a27 | ||
|
|
9c0d952543 | ||
|
|
ed4f7c7358 | ||
|
|
49f1c869c0 | ||
|
|
5e6949062f | ||
|
|
dc063b20fa | ||
|
|
f42bbfd927 | ||
|
|
f0af24d78f | ||
|
|
26fd3d48df | ||
|
|
864bbe7e12 | ||
|
|
1e995552c8 | ||
|
|
6baae97b5d | ||
|
|
bcfbbc53f8 | ||
|
|
8a916cd636 | ||
|
|
75f58a28ac | ||
|
|
e87ef431c5 | ||
|
|
b2ac538580 | ||
|
|
53b20ec104 | ||
|
|
c8ef3c4050 | ||
|
|
9113d30103 | ||
|
|
fef6e584b7 | ||
|
|
67fa47b1bb | ||
|
|
5f21c4119f | ||
|
|
734f35c504 | ||
|
|
038ae8f803 | ||
|
|
2236288e57 | ||
|
|
8f10c66160 | ||
|
|
5960dc5f2d | ||
|
|
cbf1215bb1 | ||
|
|
1b633720be | ||
|
|
adb5d6e998 | ||
|
|
3841946b61 | ||
|
|
369e2703c2 | ||
|
|
4a45ad91fa | ||
|
|
265dac45ce | ||
|
|
f152787578 | ||
|
|
640623f4fd | ||
|
|
1aff89f377 | ||
|
|
7d6b12a4ef | ||
|
|
7a3223e415 | ||
|
|
bd18688f35 | ||
|
|
f90da96820 | ||
|
|
6c8f2d2cd6 | ||
|
|
5e84305922 | ||
|
|
b3421861e6 | ||
|
|
872492ad22 | ||
|
|
5c14c7cf5e | ||
|
|
b59f0f418e | ||
|
|
06d3e27e03 | ||
|
|
b1093baef7 | ||
|
|
b3f09ff045 | ||
|
|
b33ad36f64 | ||
|
|
9254ce46eb | ||
|
|
cc8c855306 | ||
|
|
b74994b906 | ||
|
|
f652dc71bb | ||
|
|
6d2677a5e3 | ||
|
|
0c5305c174 | ||
|
|
5398576f4f | ||
|
|
3d1f570b38 | ||
|
|
fa057502f1 | ||
|
|
bb4a5cfe83 | ||
|
|
ac1fd74942 | ||
|
|
db660ac780 | ||
|
|
cdeb9a94ed | ||
|
|
6da94aef8d | ||
|
|
0a4fdc17a5 | ||
|
|
1bbba7a0ae | ||
|
|
fcc4e305e0 | ||
|
|
854796a8dd | ||
|
|
e6a41773ca | ||
|
|
f4a6d8af49 | ||
|
|
1563855251 | ||
|
|
1330e2e1e7 | ||
|
|
34fe55686d | ||
|
|
b0aa387235 | ||
|
|
c3f7aeb856 | ||
|
|
54a6ae6710 | ||
|
|
8dfc90985b | ||
|
|
99ea916c14 | ||
|
|
70151a3c19 | ||
|
|
543fb350ec | ||
|
|
ea4e3128ca | ||
|
|
ae8cbc8f45 | ||
|
|
cb156e1903 | ||
|
|
edcb6950e5 | ||
|
|
085fb1601e | ||
|
|
132a8c0aab | ||
|
|
4f3ca6fe2b | ||
|
|
13c0572fe1 | ||
|
|
fda4b67dbc | ||
|
|
aefbccbfea | ||
|
|
d1336da339 | ||
|
|
f453cefc0b | ||
|
|
b6bc09fee1 | ||
|
|
437843084b | ||
|
|
1e4c665b53 | ||
|
|
ac52befb5b | ||
|
|
50d7df45eb | ||
|
|
7e0a4e61b4 | ||
|
|
689ad644c1 | ||
|
|
f660707712 | ||
|
|
06016722d1 | ||
|
|
832d7f360c | ||
|
|
939653ecc0 | ||
|
|
610a948dcc | ||
|
|
b2284b5f4b | ||
|
|
d9e1a759a1 | ||
|
|
624b443f98 | ||
|
|
71a70bac9d | ||
|
|
0110869c89 | ||
|
|
7d4017225c | ||
|
|
94a1cc07d5 | ||
|
|
1720c82570 | ||
|
|
81bd1a1732 | ||
|
|
f421da4b9b | ||
|
|
3eaa3ca1b5 | ||
|
|
2d1ad16b4f | ||
|
|
6179864828 | ||
|
|
64d7ab22f3 | ||
|
|
4962dc618b | ||
|
|
8e2246113a | ||
|
|
2b465b0646 | ||
|
|
fdccdcebad | ||
|
|
a38bd5bedc | ||
|
|
6fe04bd280 | ||
|
|
d0a68da7e7 | ||
|
|
ede67dbc20 | ||
|
|
3d3fe5c614 | ||
|
|
7bb33d4453 | ||
|
|
0ad4dbc65a | ||
|
|
c293994d2b | ||
|
|
a557aa35f5 | ||
|
|
736e9673ad | ||
|
|
13d96e793b | ||
|
|
5e37c90dde | ||
|
|
7bcd00a87e | ||
|
|
8ef00efe84 | ||
|
|
a4101f7670 | ||
|
|
320f990f84 | ||
|
|
7297f55ca4 | ||
|
|
2cb642bbf7 | ||
|
|
ecdf9c26cd | ||
|
|
a672ab959f | ||
|
|
1571ebe4eb | ||
|
|
b8bb5e8d72 | ||
|
|
af77554250 | ||
|
|
3b68782cfb | ||
|
|
8388e36f28 | ||
|
|
051b3702b0 | ||
|
|
39accdba58 | ||
|
|
32de0aa40a | ||
|
|
9bfcdd086a | ||
|
|
1d20124467 | ||
|
|
435eca6f35 | ||
|
|
0a61db6efd | ||
|
|
7ab8405946 | ||
|
|
6fa3e99be2 | ||
|
|
3160fb2d99 | ||
|
|
1cbf16b3bf | ||
|
|
dad289b90d | ||
|
|
c795ecd21e | ||
|
|
21607f0f66 | ||
|
|
fc311a8d28 | ||
|
|
93606c6647 | ||
|
|
1adcfcc03b | ||
|
|
ed4304dc30 | ||
|
|
c0a6a21b9e | ||
|
|
a7bb599cd0 | ||
|
|
28ba4655c1 | ||
|
|
bd95a5b74c | ||
|
|
745b230097 | ||
|
|
ec303a60ed | ||
|
|
e99b0627d6 | ||
|
|
b77b6d1a18 | ||
|
|
779b23d919 | ||
|
|
a5b27cce5c | ||
|
|
ebd6891471 | ||
|
|
3cf3e04866 | ||
|
|
ff5508cb79 | ||
|
|
f07f7a1e35 | ||
|
|
881b625177 | ||
|
|
4c2dcac61a | ||
|
|
dcb00a3917 | ||
|
|
ddca49ba16 | ||
|
|
0fb92720f8 | ||
|
|
7de346b532 | ||
|
|
729f9f142e | ||
|
|
45b73e3dfb | ||
|
|
d3711b0503 | ||
|
|
9d8df8d05d | ||
|
|
97f29e4305 | ||
|
|
fcfcaa0628 | ||
|
|
d5314b028b | ||
|
|
3be5875ea9 | ||
|
|
94680c41f7 | ||
|
|
af71b63f23 | ||
|
|
e40cf46b33 | ||
|
|
5b330ab5b4 | ||
|
|
7dcfa65146 | ||
|
|
a6bb1bf096 | ||
|
|
728427cecf | ||
|
|
651b439096 | ||
|
|
7a41ff0127 |
@@ -16,12 +16,12 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 20.8.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.20.3'
|
||||
go-version: '>=1.21.3'
|
||||
|
||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||
@@ -29,6 +29,13 @@ jobs:
|
||||
- name: Build Admin dashboard UI
|
||||
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
||||
|
||||
# Temporary disable as the types can have random generated identifiers making it non-deterministic.
|
||||
#
|
||||
# # Similar to the above, the jsvm types are pregenerated locally
|
||||
# # but its here to ensure that it wasn't forgotten to be executed.
|
||||
# - name: Generate jsvm types
|
||||
# run: go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
||||
# https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - name: Run linter
|
||||
|
||||
+1
-8
@@ -7,6 +7,7 @@ before:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
# used only for tests
|
||||
- id: build_cgo
|
||||
main: ./examples/base
|
||||
binary: pocketbase
|
||||
@@ -46,14 +47,6 @@ release:
|
||||
draft: true
|
||||
|
||||
archives:
|
||||
- id: archive_cgo
|
||||
builds: [build_cgo]
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}_cgo'
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- CHANGELOG*
|
||||
|
||||
- id: archive_noncgo
|
||||
builds: [build_noncgo]
|
||||
format: zip
|
||||
|
||||
+637
-1384
File diff suppressed because it is too large
Load Diff
+1384
File diff suppressed because it is too large
Load Diff
+21
-19
@@ -1,5 +1,4 @@
|
||||
Contributing to PocketBase
|
||||
======================================================================
|
||||
# Contributing to PocketBase
|
||||
|
||||
Thanks for taking the time to improve PocketBase!
|
||||
|
||||
@@ -9,26 +8,26 @@ This document describes how to prepare a PR for a change in the main repository.
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.18+ (for making changes in the Go code)
|
||||
- Go 1.19+ (for making changes in the Go code)
|
||||
- Node 16+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
```
|
||||
git clone https://github.com/your_username/pocketbase.git
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> It is recommended to create a new branch from master for each of your bugfixes and features.
|
||||
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
|
||||
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
|
||||
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
|
||||
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
|
||||
|
||||
@@ -41,20 +40,22 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
|
||||
|
||||
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
|
||||
To run the tests, you could execute (while in the root project directory):
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
|
||||
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
|
||||
@@ -65,14 +66,15 @@ To start the Admin UI:
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
3. Start vite's dev server
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
|
||||
@@ -4,6 +4,9 @@ lint:
|
||||
test:
|
||||
go test ./... -v --cover
|
||||
|
||||
jstypes:
|
||||
go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
test-report:
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Please keep in mind that PocketBase is still under active development
|
||||
> [!WARNING]
|
||||
> Please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
@@ -30,7 +30,6 @@ The easiest way to interact with the API is to use one of the official SDK clien
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
|
||||
@@ -39,10 +38,9 @@ your own custom app specific business logic and still have a single portable exe
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# go 1.18+
|
||||
# go 1.21+
|
||||
go get github.com/pocketbase/pocketbase
|
||||
```
|
||||
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
|
||||
|
||||
### Example
|
||||
|
||||
@@ -95,14 +93,15 @@ Enable CGO only if you really need to squeeze the read/write query performance a
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.18+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
4. Start the created executable by running `./base serve`.
|
||||
|
||||
The supported build targets by the non-cgo driver at the moment are:
|
||||
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
@@ -114,6 +113,7 @@ linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
@@ -122,6 +122,7 @@ windows arm64
|
||||
|
||||
PocketBase comes with mixed bag of unit and integration tests.
|
||||
To run them, use the default `go test` command:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
@@ -134,7 +135,6 @@ If you discover a security vulnerability within PocketBase, please send an e-mai
|
||||
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
|
||||
@@ -146,14 +146,14 @@ You could help continuing its development by:
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
|
||||
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
|
||||
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
|
||||
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
|
||||
|
||||
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
|
||||
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
|
||||
|
||||
_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3._
|
||||
> [!NOTE]
|
||||
> PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
> so all feature requests will be first aligned with what we need for Presentator v3.
|
||||
|
||||
+67
-63
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -33,18 +32,28 @@ type adminApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
|
||||
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
"token": e.Token,
|
||||
"admin": e.Admin,
|
||||
@@ -62,17 +71,11 @@ func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
@@ -95,17 +98,13 @@ func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -129,30 +128,29 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against admins enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -173,17 +171,17 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -208,6 +206,10 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -228,6 +230,10 @@ func (api *adminApi) view(c echo.Context) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
}
|
||||
@@ -256,17 +262,17 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -302,17 +308,17 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -331,19 +337,17 @@ func (api *adminApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to delete admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
@@ -89,6 +91,26 @@ func TestAdminAuthWithPassword(t *testing.T) {
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -224,6 +246,29 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -278,6 +323,25 @@ func TestAdminRefresh(t *testing.T) {
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -510,6 +574,27 @@ func TestAdminDelete(t *testing.T) {
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
"OnAdminAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -637,6 +722,33 @@ func TestAdminCreate(t *testing.T) {
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890",
|
||||
"avatar":3
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -729,6 +841,7 @@ func TestAdminUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -759,6 +872,33 @@ func TestAdminUpdate(t *testing.T) {
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891",
|
||||
"avatar":5
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeUpdateRequest": 1,
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+50
-26
@@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {
|
||||
|
||||
// NewApiError creates and returns new normalized `ApiError` instance.
|
||||
func NewApiError(status int, message string, data any) *ApiError {
|
||||
message = inflector.Sentenize(message)
|
||||
|
||||
formattedData := map[string]any{}
|
||||
|
||||
if v, ok := data.(validation.Errors); ok {
|
||||
formattedData = resolveValidationErrors(v)
|
||||
}
|
||||
|
||||
return &ApiError{
|
||||
rawData: data,
|
||||
Data: formattedData,
|
||||
Data: safeErrorsData(data),
|
||||
Code: status,
|
||||
Message: strings.TrimSpace(message),
|
||||
Message: strings.TrimSpace(inflector.Sentenize(message)),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
|
||||
func safeErrorsData(data any) map[string]any {
|
||||
switch v := data.(type) {
|
||||
case validation.Errors:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]validation.Error:
|
||||
return resolveSafeErrorsData[validation.Error](v)
|
||||
case map[string]error:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]any:
|
||||
return resolveSafeErrorsData[any](v)
|
||||
default:
|
||||
return map[string]any{} // not nil to ensure that is json serialized as object
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
// extract from each validation error its error code and message.
|
||||
for name, err := range validationErrors {
|
||||
// check for nested errors
|
||||
if nestedErrs, ok := err.(validation.Errors); ok {
|
||||
result[name] = resolveValidationErrors(nestedErrs)
|
||||
for name, err := range data {
|
||||
if isNestedError(err) {
|
||||
result[name] = safeErrorsData(err)
|
||||
continue
|
||||
}
|
||||
|
||||
errCode := "validation_invalid_value" // default
|
||||
if errObj, ok := err.(validation.ErrorObject); ok {
|
||||
errCode = errObj.Code()
|
||||
}
|
||||
|
||||
result[name] = map[string]string{
|
||||
"code": errCode,
|
||||
"message": inflector.Sentenize(err.Error()),
|
||||
}
|
||||
result[name] = resolveSafeErrorItem(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isNestedError(err any) bool {
|
||||
switch err.(type) {
|
||||
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveSafeErrorItem extracts from each validation error its
|
||||
// public safe error code and message.
|
||||
func resolveSafeErrorItem(err any) map[string]string {
|
||||
// default public safe error values
|
||||
code := "validation_invalid_value"
|
||||
msg := "Invalid value."
|
||||
|
||||
// only validation errors are public safe
|
||||
if obj, ok := err.(validation.Error); ok {
|
||||
code = obj.Code()
|
||||
msg = inflector.Sentenize(obj.Error())
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"code": code,
|
||||
"message": msg,
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -37,10 +37,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
"err1": errors.New("test error"),
|
||||
"err1": errors.New("test error"), // should be normalized
|
||||
"err2": validation.ErrRequired,
|
||||
"err3": validation.Errors{
|
||||
"sub1": errors.New("test error"),
|
||||
"sub1": errors.New("test error"), // should be normalized
|
||||
"sub2": validation.ErrRequired,
|
||||
"sub3": validation.Errors{
|
||||
"sub11": validation.ErrRequired,
|
||||
@@ -50,10 +50,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
)
|
||||
|
||||
result, _ := json.Marshal(e)
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(result))
|
||||
t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
|
||||
}
|
||||
|
||||
if e.Error() != "Message_test." {
|
||||
@@ -73,7 +73,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -81,7 +81,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -102,7 +102,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -123,7 +123,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -144,7 +144,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-9
@@ -2,9 +2,7 @@ package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +10,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -25,6 +25,7 @@ func bindBackupApi(app core.App, rg *echo.Group) {
|
||||
subGroup := rg.Group("/backups", ActivityLogger(app))
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuth())
|
||||
subGroup.POST("/upload", api.upload, RequireAdminAuth())
|
||||
subGroup.GET("/:key", api.download)
|
||||
subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
|
||||
subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
|
||||
@@ -67,7 +68,7 @@ func (api *backupApi) list(c echo.Context) error {
|
||||
}
|
||||
|
||||
func (api *backupApi) create(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
|
||||
}
|
||||
|
||||
@@ -89,6 +90,28 @@ func (api *backupApi) create(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (api *backupApi) upload(c echo.Context) error {
|
||||
files, err := rest.FindUploadedFiles(c.Request(), "file")
|
||||
if err != nil {
|
||||
return NewBadRequestError("Missing or invalid uploaded file.", err)
|
||||
}
|
||||
|
||||
form := forms.NewBackupUpload(api.app)
|
||||
form.File = files[0]
|
||||
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*filesystem.File]) forms.InterceptorNextFunc[*filesystem.File] {
|
||||
return func(file *filesystem.File) error {
|
||||
if err := next(file); err != nil {
|
||||
return NewBadRequestError("Failed to upload backup.", err)
|
||||
}
|
||||
|
||||
// we don't retrieve the generated backup file because it may not be
|
||||
// available yet due to the eventually consistent nature of some S3 providers
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (api *backupApi) download(c echo.Context) error {
|
||||
fileToken := c.QueryParam("token")
|
||||
|
||||
@@ -128,12 +151,11 @@ func (api *backupApi) download(c echo.Context) error {
|
||||
}
|
||||
|
||||
func (api *backupApi) restore(c echo.Context) error {
|
||||
if api.app.Cache().Has(core.CacheKeyActiveBackup) {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
|
||||
}
|
||||
|
||||
// @todo remove the extra unescape after https://github.com/labstack/echo/issues/2447
|
||||
key, _ := url.PathUnescape(c.PathParam("key"))
|
||||
key := c.PathParam("key")
|
||||
|
||||
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -158,8 +180,8 @@ func (api *backupApi) restore(c echo.Context) error {
|
||||
// give some optimistic time to write the response
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil {
|
||||
api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -180,7 +202,7 @@ func (api *backupApi) delete(c echo.Context) error {
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
if key != "" && cast.ToString(api.app.Cache().Get(core.CacheKeyActiveBackup)) == key {
|
||||
if key != "" && cast.ToString(api.app.Store().Get(core.StoreKeyActiveBackup)) == key {
|
||||
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
|
||||
}
|
||||
|
||||
|
||||
+207
-20
@@ -1,7 +1,11 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -85,7 +89,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
@@ -98,7 +102,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
@@ -112,9 +116,9 @@ func TestBackupsCreate(t *testing.T) {
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
@@ -127,7 +131,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -152,7 +156,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
@@ -169,7 +173,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -193,6 +197,138 @@ func TestBackupsCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsUpload(t *testing.T) {
|
||||
// create dummy form data bodies
|
||||
type body struct {
|
||||
buffer io.Reader
|
||||
contentType string
|
||||
}
|
||||
bodies := make([]body, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
func() {
|
||||
zb := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(zb)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
mw := multipart.NewWriter(b)
|
||||
|
||||
mfw, err := mw.CreateFormFile("file", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(mfw, zb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mw.Close()
|
||||
|
||||
bodies[i] = body{
|
||||
buffer: b,
|
||||
contentType: mw.FormDataContentType(),
|
||||
}
|
||||
}()
|
||||
}
|
||||
// ---
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[0].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[0].contentType,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[1].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[1].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing backup name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[3].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[3].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
// create a dummy backup file to simulate existing backups
|
||||
if err := fsys.Upload([]byte("123"), "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"file":{`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[4].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[4].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsDownload(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -300,7 +436,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with valid admin file token but missing backup name",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/mizzing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -325,6 +461,22 @@ func TestBackupsDownload(t *testing.T) {
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token and backup name with escaped char",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`storage/`,
|
||||
`data.db`,
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -339,7 +491,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := 3
|
||||
expected := 4
|
||||
if total := len(files); total != expected {
|
||||
t.Fatalf("Expected %d backup(s), got %d", expected, total)
|
||||
}
|
||||
@@ -355,7 +507,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
@@ -373,7 +525,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
@@ -391,7 +543,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
@@ -410,10 +562,9 @@ func TestBackupsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// mock active backup with the same name to delete
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "test1.zip")
|
||||
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
@@ -432,16 +583,16 @@ func TestBackupsDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
// mock active backup with different name
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "new.zip")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 2 {
|
||||
t.Fatalf("Expected 2 backup files, got %d", total)
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "test1.zip"
|
||||
@@ -454,6 +605,38 @@ func TestBackupsDelete(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (backup with escaped character)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/%40test4.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "@test4.zip"
|
||||
|
||||
for _, f := range files {
|
||||
if f.Key == deletedFile {
|
||||
t.Fatalf("Expected backup %q to be deleted", deletedFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -517,7 +700,7 @@ func TestBackupsRestore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -546,6 +729,10 @@ func createTestBackups(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+66
-62
@@ -2,14 +2,16 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
@@ -25,15 +27,16 @@ const trailedAdminPath = "/_/"
|
||||
// system and app specific routes and middlewares.
|
||||
func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
e.Debug = false
|
||||
e.JSONSerializer = &rest.Serializer{
|
||||
FieldsParam: "fields",
|
||||
FieldsParam: fieldsQueryParam,
|
||||
}
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
AllowOverwritingRoute: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,38 +47,42 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
|
||||
},
|
||||
}))
|
||||
e.Pre(LoadAuthContext(app))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
e.Use(LoadAuthContext(app))
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set(ContextExecStartKey, time.Now())
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// custom error handler
|
||||
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
||||
if c.Response().Committed {
|
||||
if app.IsDebug() {
|
||||
log.Println("HTTPErrorHandler response was already committed:", err)
|
||||
}
|
||||
return
|
||||
if err == nil {
|
||||
return // no error
|
||||
}
|
||||
|
||||
var apiErr *ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
if v.Internal != nil && app.IsDebug() {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
if errors.As(err, &apiErr) {
|
||||
// already an api error...
|
||||
} else if v := new(echo.HTTPError); errors.As(err, &v) {
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
case *ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
} else {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
apiErr = NewNotFoundError("", err)
|
||||
} else {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
apiErr = v
|
||||
default:
|
||||
if err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
logRequest(app, c, apiErr)
|
||||
|
||||
if c.Response().Committed {
|
||||
return // already commited
|
||||
}
|
||||
|
||||
event := new(core.ApiErrorEvent)
|
||||
@@ -84,6 +91,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
@@ -92,19 +103,20 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
if hookErr == nil {
|
||||
if err := app.OnAfterApiError().Trigger(event); err != nil {
|
||||
app.Logger().Debug("OnAfterApiError failure", slog.String("error", hookErr.Error()))
|
||||
}
|
||||
} else {
|
||||
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
bindStaticAdminUI(app, e)
|
||||
|
||||
// default routes
|
||||
api := e.Group("/api", eagerRequestDataCache(app))
|
||||
api := e.Group("/api", eagerRequestInfoCache(app))
|
||||
bindSettingsApi(app, api)
|
||||
bindAdminApi(app, api)
|
||||
bindCollectionApi(app, api)
|
||||
@@ -116,16 +128,6 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindHealthApi(app, api)
|
||||
bindBackupApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: e,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// catch all any route
|
||||
api.Any("/*", func(c echo.Context) error {
|
||||
return echo.ErrNotFound
|
||||
@@ -188,19 +190,6 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const totalAdminsCacheKey = "@totalAdmins"
|
||||
|
||||
func updateTotalAdminsCache(app core.App) error {
|
||||
total, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Cache().Set(totalAdminsCacheKey, total)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uiCacheControl() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
@@ -215,15 +204,29 @@ func uiCacheControl() echo.MiddlewareFunc {
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdminsCacheKey = "@hasAdmins"
|
||||
|
||||
func updateHasAdminsCache(app core.App) error {
|
||||
total, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Store().Set(hasAdminsCacheKey, total > 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer admin UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
// keep totalAdminsCacheKey value up-to-date
|
||||
// keep hasAdminsCacheKey value up-to-date
|
||||
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -234,23 +237,24 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// load into cache (if not already)
|
||||
if !app.Cache().Has(totalAdminsCacheKey) {
|
||||
if err := updateTotalAdminsCache(app); err != nil {
|
||||
hasAdmins := cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
|
||||
if !hasAdmins {
|
||||
// update the cache to make sure that the admin wasn't created by another process
|
||||
if err := updateHasAdminsCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAdmins = cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
}
|
||||
|
||||
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
|
||||
|
||||
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
||||
|
||||
if totalAdmins == 0 && !hasInstallerParam {
|
||||
if !hasAdmins && !hasInstallerParam {
|
||||
// redirect to the installer page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
|
||||
}
|
||||
|
||||
if totalAdmins != 0 && hasInstallerParam {
|
||||
if hasAdmins && hasInstallerParam {
|
||||
// clear the installer param
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
+92
-8
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -213,8 +214,7 @@ func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerRequestDataCache(t *testing.T) {
|
||||
|
||||
func TestEagerRequestInfoCache(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "custom non-api group route",
|
||||
@@ -236,7 +236,7 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
|
||||
// since the unknown method is not eager cache support
|
||||
// it should fail reading the json body twice
|
||||
r := apis.RequestData(c)
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "" {
|
||||
t.Fatalf("Expected empty request data body, got, %v", r.Data)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// it is not important whether the route handler return an error since
|
||||
// we just need to ensure that the eagerRequestDataCache was registered
|
||||
// we just need to ensure that the eagerRequestInfoCache was registered
|
||||
next(c)
|
||||
|
||||
// ensure that the body was read at least once
|
||||
@@ -267,7 +267,7 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
|
||||
// since the unknown method is not eager cache support
|
||||
// it should fail reading the json body twice
|
||||
r := apis.RequestData(c)
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "" {
|
||||
t.Fatalf("Expected empty request data body, got, %v", r.Data)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// it is not important whether the route handler return an error since
|
||||
// we just need to ensure that the eagerRequestDataCache was registered
|
||||
// we just need to ensure that the eagerRequestInfoCache was registered
|
||||
next(c)
|
||||
|
||||
// ensure that the body was read at least once
|
||||
@@ -297,8 +297,7 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
c.Bind(data)
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestData(c)
|
||||
fmt.Println(r)
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
@@ -315,3 +314,88 @@ func TestEagerRequestDataCache(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return apis.NewApiError(418, "test", nil)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(418, "test")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "wrapped sql.ErrNoRows",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", sql.ErrNoRows)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "custom error",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-41
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -49,6 +48,10 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -64,6 +67,10 @@ func (api *collectionApi) view(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
}
|
||||
@@ -83,7 +90,7 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -92,18 +99,16 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) update(c echo.Context) error {
|
||||
@@ -124,7 +129,7 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -133,18 +138,16 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) delete(c echo.Context) error {
|
||||
@@ -157,21 +160,19 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
@@ -187,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
@@ -196,16 +197,14 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
+140
-104
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
@@ -45,7 +46,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -55,6 +56,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
`"id":"ib3m2700k5hlsjz"`,
|
||||
`"type":"auth"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -73,9 +75,9 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"kpv709sk2lqbqk8"`,
|
||||
`"id":"v9gwnfh02gjq1q0"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -243,7 +245,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
@@ -262,7 +264,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
@@ -299,7 +301,6 @@ func TestCollectionDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
@@ -308,6 +309,27 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/view2",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -378,7 +400,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"new"`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -402,8 +424,8 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"new"`,
|
||||
`"type":"auth"`,
|
||||
`"system":false`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
@@ -536,6 +558,28 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"options":{"minPasswordLength":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
@@ -649,7 +693,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -720,6 +764,28 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid data (eg. existing name)",
|
||||
Method: http.MethodPatch,
|
||||
@@ -757,7 +823,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
// check if the record table was renamed
|
||||
if !app.Dao().HasTable("new") {
|
||||
t.Fatal("Couldn't find record table 'new'.")
|
||||
@@ -893,88 +959,6 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// rel field change displayFields propagation
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "renaming a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"id": "w5z2x0nq",
|
||||
"type": "text",
|
||||
"name": "title_change"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"title_change"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"title_change", "id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deleting a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"type": "text",
|
||||
"name": "new_field"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new_field"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
@@ -1076,7 +1060,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1098,7 +1082,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImport(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -1131,7 +1115,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"collections":{"code":"validation_required"`,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1157,9 +1141,9 @@ func TestCollectionsImport(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 4,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1201,7 +1185,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1257,7 +1241,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1355,14 +1339,14 @@ func TestCollectionsImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 8,
|
||||
"OnModelAfterDelete": 8,
|
||||
"OnModelBeforeDelete": 9,
|
||||
"OnModelAfterDelete": 9,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1373,6 +1357,58 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + successful collections save",
|
||||
Method: http.MethodPut,
|
||||
Url: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"collections":[
|
||||
{
|
||||
"name": "import1",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "auth_without_schema",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+86
-62
@@ -1,23 +1,26 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
@@ -25,7 +28,12 @@ var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
api := fileApi{
|
||||
app: app,
|
||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 1)), // the value is arbitrary chosen and may change in the future
|
||||
thumbGenPending: new(singleflight.Group),
|
||||
thumbGenMaxWait: 60 * time.Second,
|
||||
}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
subGroup.POST("/token", api.fileToken)
|
||||
@@ -35,6 +43,18 @@ func bindFileApi(app core.App, rg *echo.Group) {
|
||||
|
||||
type fileApi struct {
|
||||
app core.App
|
||||
|
||||
// thumbGenSem is a semaphore to prevent too much concurrent
|
||||
// requests generating new thumbs at the same time.
|
||||
thumbGenSem *semaphore.Weighted
|
||||
|
||||
// thumbGenPending represents a group of currently pending
|
||||
// thumb generation processes.
|
||||
thumbGenPending *singleflight.Group
|
||||
|
||||
// thumbGenMaxWait is the maximum waiting time for starting a new
|
||||
// thumb generation process.
|
||||
thumbGenMaxWait time.Duration
|
||||
}
|
||||
|
||||
func (api *fileApi) fileToken(c echo.Context) error {
|
||||
@@ -49,23 +69,21 @@ func (api *fileApi) fileToken(c echo.Context) error {
|
||||
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
return api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.Model == nil || e.Token == "" {
|
||||
return NewBadRequestError("Failed to generate file token.", nil)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
return api.app.OnFileAfterTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnFileAfterTokenRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
@@ -102,7 +120,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
|
||||
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
|
||||
|
||||
if !api.canAccessRecord(adminOrAuthRecord, record, record.Collection().ViewRule) {
|
||||
// create a copy of the cached request data and adjust it for the current auth model
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = nil
|
||||
if adminOrAuthRecord != nil {
|
||||
if admin, _ := adminOrAuthRecord.(*models.Admin); admin != nil {
|
||||
requestInfo.Admin = admin
|
||||
} else if record, _ := adminOrAuthRecord.(*models.Record); record != nil {
|
||||
requestInfo.AuthRecord = record
|
||||
}
|
||||
}
|
||||
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
|
||||
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
|
||||
}
|
||||
}
|
||||
@@ -118,11 +148,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
baseFilesPath = fileRecord.BaseFilesPath()
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
fsys, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
defer fsys.Close()
|
||||
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
@@ -132,7 +162,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
thumbSize := c.QueryParam("thumb")
|
||||
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
|
||||
// extract the original file meta attributes and check it existence
|
||||
oAttrs, oAttrsErr := fs.Attributes(originalPath)
|
||||
oAttrs, oAttrsErr := fsys.Attributes(originalPath)
|
||||
if oAttrsErr != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
@@ -143,10 +173,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
|
||||
// create a new thumb if it doesn exists
|
||||
if exists, _ := fs.Exists(servedPath); !exists {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
|
||||
servedPath = originalPath // fallback to the original
|
||||
// create a new thumb if it doesn't exist
|
||||
if exists, _ := fsys.Exists(servedPath); !exists {
|
||||
if err := api.createThumb(c, fsys, originalPath, servedPath, thumbSize); err != nil {
|
||||
api.app.Logger().Warn(
|
||||
"Fallback to original - failed to create thumb "+servedName,
|
||||
slog.Any("error", err),
|
||||
slog.String("original", originalPath),
|
||||
slog.String("thumb", servedPath),
|
||||
)
|
||||
|
||||
// fallback to the original
|
||||
servedName = filename
|
||||
servedPath = originalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,9 +205,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
c.Response().Header().Del("X-Frame-Options")
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
@@ -207,45 +248,28 @@ func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.M
|
||||
return nil, errors.New("missing or invalid file token")
|
||||
}
|
||||
|
||||
// @todo move to a helper and maybe combine with the realtime checks when refactoring the realtime service
|
||||
func (api *fileApi) canAccessRecord(adminOrAuthRecord models.Model, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := adminOrAuthRecord.(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
}
|
||||
func (api *fileApi) createThumb(
|
||||
c echo.Context,
|
||||
fsys *filesystem.System,
|
||||
originalPath string,
|
||||
thumbPath string,
|
||||
thumbSize string,
|
||||
) error {
|
||||
ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), api.thumbGenMaxWait)
|
||||
defer cancel()
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer api.thumbGenSem.Release(1)
|
||||
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = adminOrAuthRecord.(*models.Record)
|
||||
return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
|
||||
})
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
res := <-ch
|
||||
|
||||
return nil
|
||||
}
|
||||
api.thumbGenPending.Forget(thumbPath)
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return res.Err
|
||||
}
|
||||
|
||||
@@ -2,15 +2,19 @@ package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
@@ -385,3 +389,80 @@ func TestFileDownload(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentThumbsGeneration(t *testing.T) {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
// create a dummy file field collection
|
||||
demo1, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileField := demo1.Schema.GetFieldByName("file_one")
|
||||
fileField.Options = &schema.FileOptions{
|
||||
Protected: false,
|
||||
MaxSelect: 1,
|
||||
MaxSize: 999999,
|
||||
// new thumbs
|
||||
Thumbs: []string{"111x111", "111x222", "111x333"},
|
||||
}
|
||||
demo1.Schema.AddField(fileField)
|
||||
if err := app.Dao().SaveCollection(demo1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
|
||||
|
||||
e, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
urls := []string{
|
||||
"/api/files/" + fileKey + "?thumb=111x111",
|
||||
"/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
|
||||
"/api/files/" + fileKey + "?thumb=111x222",
|
||||
"/api/files/" + fileKey + "?thumb=111x333",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(urls))
|
||||
|
||||
for _, url := range urls {
|
||||
url := url
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
e.ServeHTTP(recorder, req)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// ensure that all new requested thumbs were created
|
||||
thumbKeys := []string{
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
|
||||
}
|
||||
for _, k := range thumbKeys {
|
||||
if exists, _ := fsys.Exists(k); !exists {
|
||||
t.Fatalf("Missing thumb %q: %v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
resp := new(healthCheckResponse)
|
||||
resp.Code = http.StatusOK
|
||||
resp.Message = "API is healthy."
|
||||
resp.Data.CanBackup = !api.app.Cache().Has(core.CacheKeyActiveBackup)
|
||||
resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
+18
-18
@@ -15,27 +15,27 @@ func bindLogsApi(app core.App, rg *echo.Group) {
|
||||
api := logsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/logs", RequireAdminAuth())
|
||||
subGroup.GET("/requests", api.requestsList)
|
||||
subGroup.GET("/requests/stats", api.requestsStats)
|
||||
subGroup.GET("/requests/:id", api.requestView)
|
||||
subGroup.GET("", api.list)
|
||||
subGroup.GET("/stats", api.stats)
|
||||
subGroup.GET("/:id", api.view)
|
||||
}
|
||||
|
||||
type logsApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
var requestFilterFields = []string{
|
||||
var logFilterFields = []string{
|
||||
"rowid", "id", "created", "updated",
|
||||
"url", "method", "status", "auth",
|
||||
"remoteIp", "userIp", "referer", "userAgent",
|
||||
"level", "message", "data",
|
||||
`^data\.[\w\.\:]*\w+$`,
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsList(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
Query(api.app.LogsDao().LogQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Log{})
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
@@ -44,8 +44,8 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) stats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
filter := c.QueryParam(search.FilterQueryParam)
|
||||
|
||||
@@ -58,24 +58,24 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := api.app.LogsDao().RequestsStats(expr)
|
||||
stats, err := api.app.LogsDao().LogsStats(expr)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to generate requests stats.", err)
|
||||
return NewBadRequestError("Failed to generate logs stats.", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestView(c echo.Context) error {
|
||||
func (api *logsApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
request, err := api.app.LogsDao().FindRequestById(id)
|
||||
if err != nil || request == nil {
|
||||
log, err := api.app.LogsDao().FindLogById(id)
|
||||
if err != nil || log == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, request)
|
||||
return c.JSON(http.StatusOK, log)
|
||||
}
|
||||
|
||||
+21
-21
@@ -8,19 +8,19 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestsList(t *testing.T) {
|
||||
func TestLogsList(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -30,12 +30,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -52,12 +52,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests?filter=status>200",
|
||||
Url: "/api/logs?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -77,19 +77,19 @@ func TestRequestsList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestView(t *testing.T) {
|
||||
func TestLogView(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -99,12 +99,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (nonexisting request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -114,12 +114,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (existing request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -135,19 +135,19 @@ func TestRequestView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -157,12 +157,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -174,12 +174,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats?filter=status>200",
|
||||
Url: "/api/logs/stats?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
|
||||
+84
-75
@@ -2,9 +2,10 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
ContextAdminKey string = "admin"
|
||||
ContextAuthRecordKey string = "authRecord"
|
||||
ContextCollectionKey string = "collection"
|
||||
ContextExecStartKey string = "execStart"
|
||||
)
|
||||
|
||||
// RequireGuestOnly middleware requires a request to NOT have a valid
|
||||
@@ -285,84 +286,92 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
|
||||
func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
if err := next(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
status := httpResponse.Status
|
||||
meta := types.JsonMap{}
|
||||
logRequest(app, c, nil)
|
||||
|
||||
if err != nil {
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case *ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
meta["errorMessage"] = v.Error()
|
||||
}
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
|
||||
model := &models.Request{
|
||||
Url: httpRequest.URL.RequestURI(),
|
||||
Method: strings.ToUpper(httpRequest.Method),
|
||||
Status: status,
|
||||
Auth: requestAuth,
|
||||
UserIp: realUserIp(httpRequest, ip),
|
||||
RemoteIp: ip,
|
||||
Referer: httpRequest.Referer(),
|
||||
UserAgent: httpRequest.UserAgent(),
|
||||
Meta: meta,
|
||||
}
|
||||
// set timestamp fields before firing a new go routine
|
||||
model.RefreshCreated()
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
// ---
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
|
||||
if daysDiff > float64(app.Settings().Logs.MaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Cache().Set("lastLogsDeletedAt", now)
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Logs delete failed:", deleteErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logRequest(app core.App, c echo.Context, err *ApiError) {
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := make([]any, 0, 15)
|
||||
|
||||
attrs = append(attrs, slog.String("type", "request"))
|
||||
|
||||
started := cast.ToTime(c.Get(ContextExecStartKey))
|
||||
if !started.IsZero() {
|
||||
attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
method := strings.ToUpper(httpRequest.Method)
|
||||
status := httpResponse.Status
|
||||
requestUri := httpRequest.URL.RequestURI()
|
||||
|
||||
// parse the request error
|
||||
if err != nil {
|
||||
status = err.Code
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("error", err.Message),
|
||||
slog.Any("details", err.RawData()),
|
||||
)
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("url", requestUri),
|
||||
slog.String("method", method),
|
||||
slog.Int("status", status),
|
||||
slog.String("auth", requestAuth),
|
||||
slog.String("referer", httpRequest.Referer()),
|
||||
slog.String("userAgent", httpRequest.UserAgent()),
|
||||
)
|
||||
|
||||
if app.Settings().Logs.LogIp {
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("userIp", realUserIp(httpRequest, ip)),
|
||||
slog.String("remoteIp", ip),
|
||||
)
|
||||
}
|
||||
|
||||
// don't block on logs write
|
||||
routine.FireAndForget(func() {
|
||||
message := method + " "
|
||||
|
||||
if escaped, err := url.PathUnescape(requestUri); err == nil {
|
||||
message += escaped
|
||||
} else {
|
||||
message += requestUri
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.Logger().Error(message, attrs...)
|
||||
} else {
|
||||
app.Logger().Info(message, attrs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
|
||||
//
|
||||
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
|
||||
@@ -393,15 +402,15 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
return fallbackIp
|
||||
}
|
||||
|
||||
// eagerRequestDataCache ensures that the request data is cached in the request
|
||||
// eagerRequestInfoCache ensures that the request data is cached in the request
|
||||
// context to allow reading for example the json request body data more than once.
|
||||
func eagerRequestDataCache(app core.App) echo.MiddlewareFunc {
|
||||
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
switch c.Request().Method {
|
||||
// currently we are eagerly caching only the requests with body
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
RequestData(c)
|
||||
RequestInfo(c)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
|
||||
+275
-153
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,18 +15,20 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
api := realtimeApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/realtime", ActivityLogger(app))
|
||||
subGroup := rg.Group("/realtime")
|
||||
subGroup.GET("", api.connect)
|
||||
subGroup.POST("", api.setSubscriptions)
|
||||
subGroup.POST("", api.setSubscriptions, ActivityLogger(app))
|
||||
|
||||
api.bindEvents()
|
||||
}
|
||||
@@ -50,16 +51,19 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"OnRealtimeDisconnectRequest error",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}()
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
c.Response().Header().Set("Connection", "keep-alive")
|
||||
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
@@ -67,15 +71,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
IdleTimeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if api.app.IsDebug() {
|
||||
log.Printf("Realtime connection established: %s\n", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
@@ -83,30 +86,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
Data: []byte(`{"clientId":"` + client.Id() + `"}`),
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver PB_CONNECT)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", connectMsgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
idleTimer := time.NewTimer(idleDuration)
|
||||
idleTimeout := connectEvent.IdleTimeout
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
|
||||
for {
|
||||
@@ -116,9 +120,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
case msg, ok := <-client.Channel():
|
||||
if !ok {
|
||||
// channel is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (closed channel):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (closed channel)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,30 +134,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + e.Client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver message)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", msgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
idleTimer.Reset(idleTimeout)
|
||||
case <-c.Request().Context().Done():
|
||||
// connection is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (cancelled request):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (cancelled request)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -191,7 +197,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
Subscriptions: form.Subscriptions,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
// update auth state
|
||||
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
|
||||
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
|
||||
@@ -202,14 +208,20 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
// subscribe to the new subscriptions
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
api.app.Logger().Debug(
|
||||
"Realtime subscriptions updated.",
|
||||
slog.String("clientId", e.Client.Id()),
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRealtimeAfterSubscribeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
|
||||
@@ -269,8 +281,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("create", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record create",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -278,8 +295,13 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("update", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record update",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -287,8 +309,27 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.broadcastRecord("delete", record, true); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastDryCachedRecord("delete", record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -321,58 +362,16 @@ func (api *realtimeApi) resolveRecordCollection(model models.Model) (collection
|
||||
return collection
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// mock request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
type recordData struct {
|
||||
Action string `json:"action"`
|
||||
Record *models.Record `json:"record"`
|
||||
Record any `json:"record"` /* map or models.Record */
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error {
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("Record collection not set.")
|
||||
return errors.New("[broadcastRecord] Record collection not set.")
|
||||
}
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
@@ -380,77 +379,158 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know if the clients have permissions to view them
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Name + "/*"): collection.ListRule,
|
||||
(collection.Id + "/*"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(collection.Id + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(collection.Name + "/*?"): collection.ListRule,
|
||||
(collection.Id + "/*?"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
(collection.Name + "?"): collection.ListRule,
|
||||
(collection.Id + "?"): collection.ListRule,
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
encodedData := string(dataBytes)
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
// note: not executed concurrently to avoid races and to ensure
|
||||
// that the access checks are applied for the current record db state
|
||||
for prefix, rule := range subscriptionRuleMap {
|
||||
subs := client.Subscriptions(prefix)
|
||||
if len(subs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !api.canAccessRecord(client, data.Record, rule) {
|
||||
continue
|
||||
}
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: encodedData,
|
||||
}
|
||||
// mock request data
|
||||
requestInfo := &models.RequestInfo{
|
||||
Method: "GET",
|
||||
Query: options.Query,
|
||||
Headers: options.Headers,
|
||||
}
|
||||
requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
|
||||
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
// ignore the auth record email visibility checks for
|
||||
// auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == data.Record.Id ||
|
||||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
|
||||
data.Record.IgnoreEmailVisibility(true) // ignore
|
||||
if newData, err := json.Marshal(data); err == nil {
|
||||
msg.Data = string(newData)
|
||||
if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
rawExpand := cast.ToString(options.Query[expandQueryParam])
|
||||
if rawExpand != "" {
|
||||
expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
|
||||
if len(expandErrs) > 0 {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] expand errors",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("expand", rawExpand),
|
||||
slog.Any("errors", expandErrs),
|
||||
)
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
}
|
||||
|
||||
// ignore the auth record email visibility checks
|
||||
// for auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == cleanRecord.Id {
|
||||
if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
// check fields
|
||||
rawFields := cast.ToString(options.Query[fieldsQueryParam])
|
||||
if rawFields != "" {
|
||||
decoded, err := rest.PickFields(cleanRecord, rawFields)
|
||||
if err == nil {
|
||||
data.Record = decoded
|
||||
} else {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] pick fields error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("fields", rawFields),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] data marshal error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: sub,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
if dryCache {
|
||||
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
|
||||
if !ok {
|
||||
messages = []subscriptions.Message{msg}
|
||||
} else {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
client.Set(dryCacheKey, messages)
|
||||
} else {
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
|
||||
key := action + "/" + record.Id
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
|
||||
for _, client := range clients {
|
||||
messages, ok := client.Get(key).([]subscriptions.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
client.Unset(key)
|
||||
|
||||
client := client
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
for _, msg := range messages {
|
||||
client.Send(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type getter interface {
|
||||
Get(string) any
|
||||
}
|
||||
@@ -468,3 +548,45 @@ func extractAuthIdFromGetter(val getter) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(
|
||||
record *models.Record,
|
||||
requestInfo *models.RequestInfo,
|
||||
accessRule *string,
|
||||
) bool {
|
||||
// check the access rule
|
||||
// ---
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, requestInfo, accessRule); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// check the subscription client-side filter (if any)
|
||||
// ---
|
||||
filter := cast.ToString(requestInfo.Query[search.FilterQueryParam])
|
||||
if filter == "" {
|
||||
return true // no further checks needed
|
||||
}
|
||||
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
+12
-9
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -22,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`id:`,
|
||||
@@ -34,7 +36,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, 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()))
|
||||
}
|
||||
@@ -44,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
@@ -58,7 +61,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, 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()))
|
||||
}
|
||||
@@ -68,11 +71,11 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
@@ -80,7 +83,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
return hook.StopPropagation
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, 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()))
|
||||
}
|
||||
@@ -125,7 +128,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if len(client.Subscriptions()) != 0 {
|
||||
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
|
||||
}
|
||||
@@ -146,7 +149,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
expectedSubs := []string{"test1", "test2"}
|
||||
if len(expectedSubs) != len(client.Subscriptions()) {
|
||||
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
|
||||
@@ -176,7 +179,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
t.Errorf("Expected admin auth model, got nil")
|
||||
@@ -200,7 +203,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
@@ -225,7 +228,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
|
||||
+133
-122
@@ -4,8 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -65,26 +66,23 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
State string `json:"state"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
// technically could be omitted if the provider doesn't support PKCE,
|
||||
// but to avoid breaking existing typed clients we'll return them as empty string
|
||||
CodeVerifier string `json:"codeVerifier"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
@@ -96,12 +94,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
authOptions := collection.AuthOptions()
|
||||
|
||||
result := struct {
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
UsernamePassword bool `json:"usernamePassword"`
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
OnlyVerified bool `json:"onlyVerified"`
|
||||
}{
|
||||
UsernamePassword: authOptions.AllowUsernameAuth,
|
||||
EmailPassword: authOptions.AllowEmailAuth,
|
||||
OnlyVerified: authOptions.OnlyVerified,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
@@ -117,46 +117,61 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
|
||||
provider, err := auth.NewProviderByName(name)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug("Missing or invalid provier name", slog.String("name", name))
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Failed to setup provider",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(43)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
urlOpts := []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
info := providerInfo{
|
||||
Name: name,
|
||||
DisplayName: provider.DisplayName(),
|
||||
State: security.RandomString(30),
|
||||
}
|
||||
|
||||
if name == auth.NameApple {
|
||||
if info.DisplayName == "" {
|
||||
info.DisplayName = name
|
||||
}
|
||||
|
||||
urlOpts := []oauth2.AuthCodeOption{}
|
||||
|
||||
// custom providers url options
|
||||
switch name {
|
||||
case auth.NameApple:
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
|
||||
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
|
||||
}
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthUrl: provider.BuildAuthUrl(
|
||||
state,
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
|
||||
})
|
||||
if provider.PKCE() {
|
||||
info.CodeVerifier = security.RandomString(43)
|
||||
info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
|
||||
info.CodeChallengeMethod = "S256"
|
||||
urlOpts = append(urlOpts,
|
||||
oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
|
||||
)
|
||||
}
|
||||
|
||||
info.AuthUrl = provider.BuildAuthUrl(
|
||||
info.State,
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=" // empty redirect_uri so that users can append their redirect url
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, info)
|
||||
}
|
||||
|
||||
// sort providers
|
||||
sort.SliceStable(result.AuthProviders, func(i, j int) bool {
|
||||
return result.AuthProviders[i].Name < result.AuthProviders[j].Name
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -192,8 +207,8 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
event.IsNewRecord = true
|
||||
// clone the current request data and assign the form create data as its body data
|
||||
requestData := *RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -206,7 +221,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
}
|
||||
|
||||
if *collection.CreateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -251,17 +266,13 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
IsNew: event.IsNewRecord,
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
|
||||
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -291,17 +302,13 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -336,30 +343,32 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send password reset email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -386,17 +395,17 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -426,30 +435,32 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send verification email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against users enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
@@ -476,17 +487,17 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -511,23 +522,23 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
@@ -554,17 +565,17 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -628,19 +639,19 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return NewBadRequestError("Cannot unlink the external auth provider.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -672,10 +683,10 @@ func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: oauth2SubscriptionTopic,
|
||||
Data: string(encodedData),
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Channel() <- msg
|
||||
client.Send(msg)
|
||||
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "../_/#/auth/oauth2-redirect")
|
||||
}
|
||||
|
||||
+218
-13
@@ -2,12 +2,14 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -38,6 +40,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":true`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":false`,
|
||||
`"authProviders":[{`,
|
||||
`"name":"gitlab"`,
|
||||
`"state":`,
|
||||
@@ -56,6 +59,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":false`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":true`,
|
||||
`"authProviders":[]`,
|
||||
},
|
||||
},
|
||||
@@ -210,7 +214,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and valid password in allowed collection",
|
||||
Name: "valid email (unverified) and valid password in allowed collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -223,6 +227,48 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":false`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// onlyVerified collection check
|
||||
{
|
||||
Name: "unverified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
@@ -280,6 +326,28 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// after hooks error checks
|
||||
{
|
||||
Name: "OnRecordAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2_username",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -353,6 +421,60 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unverified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im8xeTBkZDBzcGQ3ODZtZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.-JYlrz5DcGzvb0nYx-xqnSFMu9dupyKY7Vg_FUm0OaM",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"record":`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"verified":true`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-refresh?expand=rel,missing",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -494,10 +616,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -530,6 +650,29 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -671,10 +814,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -732,6 +873,27 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmVerificationRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -815,7 +977,7 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"newEmail":{"code":"validation_record_email_exists"`,
|
||||
`"newEmail":{"code":"validation_record_email_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -932,6 +1094,28 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
`"token":{"code":"validation_token_collection_mismatch"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmEmailChangeRequest": 1,
|
||||
"OnRecordAfterConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -1083,7 +1267,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1129,7 +1313,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1140,6 +1324,27 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -1236,7 +1441,7 @@ func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
|
||||
expectedParams := []string{`"state"`, `"code"`}
|
||||
for _, p := range expectedParams {
|
||||
if !strings.Contains(msg.Data, p) {
|
||||
if !strings.Contains(string(msg.Data), p) {
|
||||
t.Errorf("Couldn't find %s in \n%v", p, msg.Data)
|
||||
}
|
||||
}
|
||||
|
||||
+89
-98
@@ -2,9 +2,8 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -16,8 +15,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordCrudApi(app core.App, rg *echo.Group) {
|
||||
@@ -45,14 +42,14 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
@@ -60,20 +57,15 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(
|
||||
api.app.Dao(),
|
||||
collection,
|
||||
requestData,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by admins
|
||||
requestData.Admin != nil,
|
||||
requestInfo.Admin != nil,
|
||||
)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
|
||||
// views don't have "rowid" so we fallback to "id"
|
||||
if collection.IsView() {
|
||||
searchProvider.CountCol("id")
|
||||
}
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
@@ -81,7 +73,7 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
@@ -91,8 +83,12 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil {
|
||||
api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -110,16 +106,16 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ViewRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -141,8 +137,17 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich view record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
@@ -155,23 +160,23 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.CreateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
hasFullManageAccess := requestData.Admin != nil
|
||||
hasFullManageAccess := requestInfo.Admin != nil
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
// available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
@@ -185,7 +190,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,7 +205,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -225,7 +230,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -234,22 +239,25 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich create record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
@@ -263,26 +271,26 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.UpdateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
// and available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -300,7 +308,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
|
||||
form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
@@ -314,7 +322,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -323,22 +331,25 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich update record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
@@ -352,16 +363,16 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.DeleteRule == nil {
|
||||
if requestInfo.Admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -382,38 +393,18 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+101
-7
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
@@ -41,9 +43,16 @@ func TestRecordCrudList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only filter/sort (aka. @collection)",
|
||||
Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
|
||||
Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?sort=@request.auth.title",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
@@ -460,6 +469,22 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view collection with numeric ids",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"id":"1"`,
|
||||
`"id":"2"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -729,6 +754,16 @@ func TestRecordCrudView(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view record with numeric id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records/1",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"1"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -836,6 +871,27 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
Method: http.MethodDelete,
|
||||
@@ -854,7 +910,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
|
||||
|
||||
// check if all the external auths records were deleted
|
||||
@@ -941,7 +997,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
recId := "84nmscqy84lsi1t"
|
||||
rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
|
||||
if rec != nil {
|
||||
@@ -1187,6 +1243,25 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -1745,6 +1820,25 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "try to change the id of an existing record",
|
||||
Method: http.MethodPatch,
|
||||
@@ -1945,7 +2039,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
|
||||
if !record.ValidatePassword("12345678") {
|
||||
t.Fatal("Password update failed.")
|
||||
@@ -1988,7 +2082,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
if !record.ValidatePassword("12345678") {
|
||||
t.Fatal("Password update failed.")
|
||||
@@ -2050,7 +2144,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
|
||||
if !record.ValidatePassword("123456789") {
|
||||
t.Fatal("Password update failed.")
|
||||
|
||||
+94
-30
@@ -3,6 +3,7 @@ package apis
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -13,23 +14,36 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
const ContextRequestInfoKey = "requestInfo"
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
const expandQueryParam = "expand"
|
||||
const fieldsQueryParam = "fields"
|
||||
|
||||
// Deprecated: Use RequestInfo instead.
|
||||
func RequestData(c echo.Context) *models.RequestInfo {
|
||||
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
|
||||
return RequestInfo(c)
|
||||
}
|
||||
|
||||
// RequestInfo exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
func RequestInfo(c echo.Context) *models.RequestInfo {
|
||||
// return cached to avoid copying the body multiple times
|
||||
if v := c.Get(ContextRequestDataKey); v != nil {
|
||||
if data, ok := v.(*models.RequestData); ok {
|
||||
if v := c.Get(ContextRequestInfoKey); v != nil {
|
||||
if data, ok := v.(*models.RequestInfo); ok {
|
||||
// refresh auth state
|
||||
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
result := &models.RequestInfo{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
@@ -40,7 +54,7 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
// ("X-Token" is converted to "x_token")
|
||||
for k, v := range c.Request().Header {
|
||||
if len(v) > 0 {
|
||||
result.Headers[strings.ToLower(strings.ReplaceAll(k, "-", "_"))] = v[0]
|
||||
result.Headers[inflector.Snakecase(k)] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +63,24 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
echo.BindQueryParams(c, &result.Query)
|
||||
rest.BindBody(c, &result.Data)
|
||||
|
||||
c.Set(ContextRequestDataKey, result)
|
||||
c.Set(ContextRequestInfoKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
// RecordAuthResponse writes standardised json record auth response
|
||||
// into the specified request context.
|
||||
func RecordAuthResponse(
|
||||
app core.App,
|
||||
c echo.Context,
|
||||
authRecord *models.Record,
|
||||
meta any,
|
||||
finalizers ...func(token string) error,
|
||||
) error {
|
||||
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
|
||||
return NewForbiddenError("Please verify your email first.", nil)
|
||||
}
|
||||
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
@@ -68,6 +94,10 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow always returning the email address of the authenticated account
|
||||
e.Record.IgnoreEmailVisibility(true)
|
||||
|
||||
@@ -75,16 +105,16 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
// create a copy of the cached request data and adjust it to the current auth record
|
||||
requestData := *RequestData(e.HttpContext)
|
||||
requestData.Admin = nil
|
||||
requestData.AuthRecord = e.Record
|
||||
requestInfo := *RequestInfo(e.HttpContext)
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = e.Record
|
||||
failed := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
expandFetch(app.Dao(), &requestInfo),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
if len(failed) > 0 {
|
||||
app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +127,12 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(e.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -114,9 +150,9 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
|
||||
// - ensures that the emails of the auth records and their expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
@@ -128,7 +164,7 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
return nil // nothing to expand
|
||||
}
|
||||
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Failed to expand: %v", errs)
|
||||
}
|
||||
@@ -139,11 +175,11 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(
|
||||
dao *daos.Dao,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) daos.ExpandFetchFunc {
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
return nil // admins can access everything
|
||||
}
|
||||
|
||||
@@ -152,7 +188,7 @@ func expandFetch(
|
||||
}
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -165,7 +201,7 @@ func expandFetch(
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
|
||||
}
|
||||
|
||||
return records, err
|
||||
@@ -179,13 +215,13 @@ func expandFetch(
|
||||
func autoIgnoreAuthRecordsEmailVisibility(
|
||||
dao *daos.Dao,
|
||||
records []*models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) error {
|
||||
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
for _, rec := range records {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
@@ -201,8 +237,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
@@ -218,7 +254,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -247,7 +283,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
func hasAuthManageAccess(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
@@ -259,12 +295,12 @@ func hasAuthManageAccess(
|
||||
return false // only for admins (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestData == nil || requestData.AuthRecord == nil {
|
||||
if requestInfo == nil || requestInfo.AuthRecord == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -278,3 +314,31 @@ func hasAuthManageAccess(
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var adminOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
// @todo consider moving the rules check to the RecordFieldResolver.
|
||||
//
|
||||
// checkForAdminOnlyRuleFields loosely checks and returns an error if
|
||||
// the provided RequestInfo contains rule fields that only the admin can use.
|
||||
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
|
||||
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
|
||||
return nil // admin or nothing to check
|
||||
}
|
||||
|
||||
for _, param := range ruleQueryParams {
|
||||
v, _ := requestInfo.Query[param].(string)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, field := range adminOnlyRuleFields {
|
||||
if strings.Contains(v, field) {
|
||||
return NewForbiddenError("Only admins can filter by "+field, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestData(t *testing.T) {
|
||||
func TestRequestInfo(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
@@ -29,10 +29,10 @@ func TestRequestData(t *testing.T) {
|
||||
dummyAdmin.Id = "id2"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
result := apis.RequestData(c)
|
||||
result := apis.RequestInfo(c)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected *models.RequestData instance, got nil")
|
||||
t.Fatal("Expected *models.RequestInfo instance, got nil")
|
||||
}
|
||||
|
||||
if result.Method != http.MethodPost {
|
||||
@@ -83,6 +83,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unverfiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
@@ -97,6 +102,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record but with unverified email in onlyVerified collection",
|
||||
record: unverfiedAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
|
||||
+141
-38
@@ -8,41 +8,64 @@ import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// ServeOptions defines an optional struct for apis.Serve().
|
||||
type ServeOptions struct {
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
ShowStartBanner bool
|
||||
HttpAddr string
|
||||
HttpsAddr string
|
||||
AllowedOrigins []string // optional list of CORS origins (default to "*")
|
||||
BeforeServeFunc func(server *http.Server) error
|
||||
|
||||
// HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
|
||||
HttpAddr string
|
||||
|
||||
// HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
|
||||
HttpsAddr string
|
||||
|
||||
// Optional domains list to use when issuing the TLS certificate.
|
||||
//
|
||||
// If not set, the host from the bound server address will be used.
|
||||
//
|
||||
// For convenience, for each "non-www" domain a "www" entry and
|
||||
// redirect will be automatically added.
|
||||
CertificateDomains []string
|
||||
|
||||
// AllowedOrigins is an optional list of CORS origins (default to "*").
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Serve starts a new app web server.
|
||||
func Serve(app core.App, options *ServeOptions) error {
|
||||
if options == nil {
|
||||
options = &ServeOptions{}
|
||||
}
|
||||
|
||||
if len(options.AllowedOrigins) == 0 {
|
||||
options.AllowedOrigins = []string{"*"}
|
||||
//
|
||||
// NB! The app should be bootstrapped before starting the web server.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// app.Bootstrap()
|
||||
// apis.Serve(app, apis.ServeConfig{
|
||||
// HttpAddr: "127.0.0.1:8080",
|
||||
// ShowStartBanner: false,
|
||||
// })
|
||||
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
if len(config.AllowedOrigins) == 0 {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
@@ -56,33 +79,75 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
|
||||
router, err := InitApi(app)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: options.AllowedOrigins,
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := options.HttpAddr
|
||||
if options.HttpsAddr != "" {
|
||||
mainAddr = options.HttpsAddr
|
||||
mainAddr := config.HttpAddr
|
||||
if config.HttpsAddr != "" {
|
||||
mainAddr = config.HttpsAddr
|
||||
}
|
||||
|
||||
mainHost, _, _ := net.SplitHostPort(mainAddr)
|
||||
var wwwRedirects []string
|
||||
|
||||
certManager := autocert.Manager{
|
||||
// extract the host names for the certificate host policy
|
||||
hostNames := config.CertificateDomains
|
||||
if len(hostNames) == 0 {
|
||||
host, _, _ := net.SplitHostPort(mainAddr)
|
||||
hostNames = append(hostNames, host)
|
||||
}
|
||||
for _, host := range hostNames {
|
||||
if strings.HasPrefix(host, "www.") {
|
||||
continue // explicitly set www host
|
||||
}
|
||||
|
||||
wwwHost := "www." + host
|
||||
if !list.ExistInSlice(wwwHost, hostNames) {
|
||||
hostNames = append(hostNames, wwwHost)
|
||||
wwwRedirects = append(wwwRedirects, wwwHost)
|
||||
}
|
||||
}
|
||||
|
||||
// implicit www->non-www redirect(s)
|
||||
if len(wwwRedirects) > 0 {
|
||||
router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
host := c.Request().Host
|
||||
|
||||
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) {
|
||||
return c.Redirect(
|
||||
http.StatusTemporaryRedirect,
|
||||
(c.Scheme() + "://" + host[4:] + c.Request().RequestURI),
|
||||
)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
|
||||
HostPolicy: autocert.HostWhitelist(hostNames...),
|
||||
}
|
||||
|
||||
serverConfig := &http.Server{
|
||||
// base request context used for cancelling long running requests
|
||||
// like the SSE connections
|
||||
baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
|
||||
defer cancelBaseCtx()
|
||||
|
||||
server := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
@@ -91,18 +156,31 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return baseCtx
|
||||
},
|
||||
}
|
||||
|
||||
if options.BeforeServeFunc != nil {
|
||||
if err := options.BeforeServeFunc(serverConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: router,
|
||||
Server: server,
|
||||
CertManager: certManager,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if options.ShowStartBanner {
|
||||
if config.ShowStartBanner {
|
||||
schema := "http"
|
||||
if options.HttpsAddr != "" {
|
||||
addr := server.Addr
|
||||
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
addr = config.CertificateDomains[0]
|
||||
}
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
@@ -112,34 +190,59 @@ func Serve(app core.App, options *ServeOptions) error {
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, serverConfig.Addr),
|
||||
color.CyanString("%s://%s", schema, addr),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
|
||||
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
|
||||
}
|
||||
|
||||
// WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
|
||||
// Note that the WaitGroup would not do anything if the app.OnTerminate() hook isn't triggered.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// try to gracefully shutdown the server on app termination
|
||||
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
||||
cancelBaseCtx()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
serverConfig.Shutdown(ctx)
|
||||
|
||||
wg.Add(1)
|
||||
server.Shutdown(ctx)
|
||||
if e.IsRestart {
|
||||
// wait for execve and other handlers up to 5 seconds before exit
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
wg.Done()
|
||||
})
|
||||
} else {
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// wait for the graceful shutdown to complete before exit
|
||||
defer wg.Wait()
|
||||
|
||||
// ---
|
||||
// @todo consider removing the server return value because it is
|
||||
// not really useful when combined with the blocking serve calls
|
||||
// ---
|
||||
|
||||
// start HTTPS server
|
||||
if options.HttpsAddr != "" {
|
||||
if config.HttpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if options.HttpAddr != "" {
|
||||
go http.ListenAndServe(options.HttpAddr, certManager.HTTPHandler(nil))
|
||||
if config.HttpAddr != "" {
|
||||
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
|
||||
}
|
||||
|
||||
return serverConfig.ListenAndServeTLS("", "")
|
||||
return server, server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// OR start HTTP server
|
||||
return serverConfig.ListenAndServe()
|
||||
return server, server.ListenAndServe()
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
|
||||
+16
-15
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -38,6 +37,10 @@ func (api *settingsApi) list(c echo.Context) error {
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
})
|
||||
}
|
||||
@@ -55,7 +58,7 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
@@ -64,23 +67,21 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *settingsApi) testS3(c echo.Context) error {
|
||||
|
||||
+42
-3
@@ -6,12 +6,14 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -75,6 +77,11 @@ func TestSettingsList(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -153,6 +160,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -220,6 +232,11 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -231,6 +248,28 @@ func TestSettingsSet(t *testing.T) {
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnSettingsBeforeUpdateRequest": 1,
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -367,7 +406,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
@@ -402,7 +441,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
@@ -437,7 +476,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
+26
-10
@@ -17,14 +17,30 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
var httpsAddr string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Starts the web server (default to 127.0.0.1:8090)",
|
||||
Use: "serve [domain(s)]",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
err := apis.Serve(app, &apis.ServeOptions{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
// set default listener addresses if at least one domain is specified
|
||||
if len(args) > 0 {
|
||||
if httpAddr == "" {
|
||||
httpAddr = "0.0.0.0:80"
|
||||
}
|
||||
if httpsAddr == "" {
|
||||
httpsAddr = "0.0.0.0:443"
|
||||
}
|
||||
} else {
|
||||
if httpAddr == "" {
|
||||
httpAddr = "127.0.0.1:8090"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
CertificateDomains: args,
|
||||
})
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
@@ -43,15 +59,15 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
command.PersistentFlags().StringVar(
|
||||
&httpAddr,
|
||||
"http",
|
||||
"127.0.0.1:8090",
|
||||
"api HTTP server address",
|
||||
"",
|
||||
"TCP address to listen for the HTTP server\n(if domain args are specified - default to 0.0.0.0:80, otherwise - default to 127.0.0.1:8090)",
|
||||
)
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&httpsAddr,
|
||||
"https",
|
||||
"",
|
||||
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
|
||||
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
|
||||
)
|
||||
|
||||
return command
|
||||
|
||||
+88
-91
@@ -5,6 +5,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@@ -48,6 +49,9 @@ type App interface {
|
||||
// the users table from LogsDao will result in error.
|
||||
LogsDao() *daos.Dao
|
||||
|
||||
// Logger returns the active app logger.
|
||||
Logger() *slog.Logger
|
||||
|
||||
// DataDir returns the app data directory path.
|
||||
DataDir() string
|
||||
|
||||
@@ -55,16 +59,18 @@ type App interface {
|
||||
// (used for settings encryption).
|
||||
EncryptionEnv() string
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
IsDebug() bool
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
IsDev() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
Cache() *store.Store[any]
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
SubscriptionsBroker() *subscriptions.Broker
|
||||
|
||||
@@ -74,14 +80,14 @@ type App interface {
|
||||
// NewFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing regular app files (eg. collection uploads).
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewFilesystem() (*filesystem.System, error)
|
||||
|
||||
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing app backups.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewBackupsFilesystem() (*filesystem.System, error)
|
||||
|
||||
@@ -131,21 +137,21 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// OnBeforeBootstrap hook is triggered before initializing the main
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// OnAfterBootstrap hook is triggered after initializing the main
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
// allowing you to adjust its options and attach new routes or middlewares.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
// or to return a completely different API response.
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
@@ -162,7 +168,7 @@ type App interface {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnModelBeforeCreate hook is triggered before inserting a new
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -170,7 +176,7 @@ type App interface {
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
// inserting a new model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -178,7 +184,7 @@ type App interface {
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeUpdate hook is triggered before updating existing
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -186,7 +192,7 @@ type App interface {
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
// existing model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -194,15 +200,15 @@ type App interface {
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
// OnModelAfterDelete hook is triggered after successfully deleting an
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -213,22 +219,18 @@ type App interface {
|
||||
// Mailer event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an admin.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an admin, allowing you
|
||||
// to inspect and customize the email message that is being sent.
|
||||
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerAfterAdminResetPasswordSend hook is triggered after
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -243,11 +245,9 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right
|
||||
// before sending a verification email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -263,10 +263,8 @@ type App interface {
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// sending a confirmation new address email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -293,14 +291,14 @@ type App interface {
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// OnRealtimeBeforeMessageSend hook is triggered right before sending
|
||||
// an SSE message to a client.
|
||||
//
|
||||
// Returning [hook.StopPropagation] will prevent sending the message.
|
||||
// Returning any other non-nil error will close the realtime connection.
|
||||
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right after sending
|
||||
// OnRealtimeAfterMessageSend hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
@@ -328,8 +326,7 @@ type App interface {
|
||||
// Settings update request (after request data load and before settings persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or
|
||||
// implement completely different persistence behavior
|
||||
// (returning [hook.StopPropagation]).
|
||||
// implement completely different persistence behavior.
|
||||
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
|
||||
|
||||
// OnSettingsAfterUpdateRequest hook is triggered after each
|
||||
@@ -383,7 +380,7 @@ type App interface {
|
||||
// Admin create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
|
||||
|
||||
// OnAdminAfterCreateRequest hook is triggered after each
|
||||
@@ -394,7 +391,7 @@ type App interface {
|
||||
// Admin update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
|
||||
|
||||
// OnAdminAfterUpdateRequest hook is triggered after each
|
||||
@@ -405,7 +402,7 @@ type App interface {
|
||||
// Admin delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
|
||||
|
||||
// OnAdminAfterDeleteRequest hook is triggered after each
|
||||
@@ -434,7 +431,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
@@ -445,7 +442,7 @@ type App interface {
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
@@ -456,7 +453,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
@@ -482,7 +479,7 @@ type App interface {
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
// or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -500,11 +497,11 @@ type App interface {
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
// change the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -523,7 +520,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -538,11 +535,39 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -561,7 +586,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -580,7 +605,7 @@ type App interface {
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
// completely different verification behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -599,7 +624,7 @@ type App interface {
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -618,7 +643,7 @@ type App interface {
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
// completely different request email change behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -637,7 +662,7 @@ type App interface {
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -652,34 +677,6 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -706,7 +703,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -725,7 +722,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -744,7 +741,7 @@ type App interface {
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -777,7 +774,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
|
||||
|
||||
// OnCollectionAfterCreateRequest hook is triggered after each
|
||||
@@ -788,7 +785,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
|
||||
|
||||
// OnCollectionAfterUpdateRequest hook is triggered after each
|
||||
@@ -799,7 +796,7 @@ type App interface {
|
||||
// Collection delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
|
||||
|
||||
// OnCollectionAfterDeleteRequest hook is triggered after each
|
||||
@@ -810,7 +807,7 @@ type App interface {
|
||||
// collections import request (after request data load and before the actual import).
|
||||
//
|
||||
// Could be used to additionally validate the imported collections or
|
||||
// to implement completely different import behavior (returning [hook.StopPropagation]).
|
||||
// to implement completely different import behavior.
|
||||
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
|
||||
|
||||
// OnCollectionsAfterImportRequest hook is triggered after each
|
||||
|
||||
+197
-67
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -18,10 +19,14 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,8 +44,10 @@ var _ App = (*BaseApp)(nil)
|
||||
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
|
||||
|
||||
// configurable parameters
|
||||
isDebug bool
|
||||
isDev bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
dataMaxOpenConns int
|
||||
@@ -49,11 +56,12 @@ type BaseApp struct {
|
||||
logsMaxIdleConns int
|
||||
|
||||
// internals
|
||||
cache *store.Store[any]
|
||||
store *store.Store[any]
|
||||
settings *settings.Settings
|
||||
dao *daos.Dao
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
logger *slog.Logger
|
||||
|
||||
// app event hooks
|
||||
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
|
||||
@@ -167,9 +175,9 @@ type BaseApp struct {
|
||||
|
||||
// BaseAppConfig defines a BaseApp configuration option
|
||||
type BaseAppConfig struct {
|
||||
IsDev bool
|
||||
DataDir string
|
||||
EncryptionEnv string
|
||||
IsDebug bool
|
||||
DataMaxOpenConns int // default to 500
|
||||
DataMaxIdleConns int // default 20
|
||||
LogsMaxOpenConns int // default to 100
|
||||
@@ -180,16 +188,16 @@ type BaseAppConfig struct {
|
||||
// configured with the provided arguments.
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
isDev: config.IsDev,
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
encryptionEnv: config.EncryptionEnv,
|
||||
dataMaxOpenConns: config.DataMaxOpenConns,
|
||||
dataMaxIdleConns: config.DataMaxIdleConns,
|
||||
logsMaxOpenConns: config.LogsMaxOpenConns,
|
||||
logsMaxIdleConns: config.LogsMaxIdleConns,
|
||||
cache: store.New[any](nil),
|
||||
store: store.New[any](nil),
|
||||
settings: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
|
||||
@@ -314,6 +322,17 @@ func (app *BaseApp) IsBootstrapped() bool {
|
||||
return app.dao != nil && app.logsDao != nil && app.settings != nil
|
||||
}
|
||||
|
||||
// Logger returns the default app logger.
|
||||
//
|
||||
// If the application is not bootstrapped yet, fallbacks to slog.Default().
|
||||
func (app *BaseApp) Logger() *slog.Logger {
|
||||
if app.logger == nil {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
return app.logger
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
@@ -343,17 +362,17 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.initLogger(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we don't check for an error because the db migrations may have not been executed yet
|
||||
app.RefreshSettings()
|
||||
|
||||
// cleanup the pb_data temp directory (if any)
|
||||
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
|
||||
|
||||
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return app.OnAfterBootstrap().Trigger(event)
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -379,7 +398,6 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
|
||||
app.dao = nil
|
||||
app.logsDao = nil
|
||||
app.settings = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -443,10 +461,11 @@ func (app *BaseApp) EncryptionEnv() string {
|
||||
return app.encryptionEnv
|
||||
}
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
func (app *BaseApp) IsDebug() bool {
|
||||
return app.isDebug
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
//
|
||||
// When enabled logs, executed sql statements, etc. are printed to the stderr.
|
||||
func (app *BaseApp) IsDev() bool {
|
||||
return app.isDev
|
||||
}
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
@@ -454,9 +473,15 @@ func (app *BaseApp) Settings() *settings.Settings {
|
||||
return app.settings
|
||||
}
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
func (app *BaseApp) Cache() *store.Store[any] {
|
||||
return app.cache
|
||||
color.Yellow("app.Store() is soft-deprecated. Please replace it with app.Store().")
|
||||
return app.Store()
|
||||
}
|
||||
|
||||
// Store returns the app internal runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
@@ -475,6 +500,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
Password: app.Settings().Smtp.Password,
|
||||
Tls: app.Settings().Smtp.Tls,
|
||||
AuthMethod: app.Settings().Smtp.AuthMethod,
|
||||
LocalName: app.Settings().Smtp.LocalName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,7 +511,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
// for managing regular app files (eg. collection uploads)
|
||||
// based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.S3.Enabled {
|
||||
@@ -506,7 +532,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing app backups based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.Backups.S3.Enabled {
|
||||
@@ -537,17 +563,17 @@ func (app *BaseApp) Restart() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// optimistically reset the app bootstrap state
|
||||
app.ResetBootstrapState()
|
||||
return app.OnTerminate().Trigger(&TerminateEvent{
|
||||
App: app,
|
||||
IsRestart: true,
|
||||
}, func(e *TerminateEvent) error {
|
||||
e.App.ResetBootstrapState()
|
||||
|
||||
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
|
||||
// restart the app bootstrap state
|
||||
app.Bootstrap()
|
||||
// attempt to restart the bootstrap process in case execve returns an error for some reason
|
||||
defer e.App.Bootstrap()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return syscall.Exec(execPath, os.Args, os.Environ())
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
@@ -559,7 +585,7 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
encryptionKey := os.Getenv(app.EncryptionEnv())
|
||||
|
||||
storedSettings, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -573,6 +599,13 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// reload handler level (if initialized and not in dev mode)
|
||||
if !app.IsDev() && app.Logger() != nil {
|
||||
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
|
||||
h.SetLevel(slog.Level(app.settings.Logs.MinLevel))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -992,7 +1025,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
@@ -1000,7 +1033,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
@@ -1023,7 +1056,7 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
@@ -1031,17 +1064,16 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
if app.IsDebug() {
|
||||
if app.IsDev() {
|
||||
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
|
||||
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
|
||||
}
|
||||
|
||||
@@ -1053,58 +1085,58 @@ func (app *BaseApp) initDataDB() error {
|
||||
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeCreate().Trigger(e)
|
||||
return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterCreate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterCreate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeUpdate().Trigger(e)
|
||||
return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterUpdate().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterUpdate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeDelete().Trigger(e)
|
||||
return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
if err := app.OnModelAfterDelete().Trigger(e); err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
return app.OnModelAfterDelete().Trigger(e)
|
||||
}
|
||||
|
||||
return dao
|
||||
@@ -1133,14 +1165,13 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
//
|
||||
// @todo consider creating a bg process queue so that the
|
||||
// call could be "retried" in case of a failure.
|
||||
routine.FireAndForget(func() {
|
||||
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
if err := deletePrefix(prefix); err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1148,12 +1179,111 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
app.ResetBootstrapState()
|
||||
if err := app.initAutobackupHooks(); err != nil {
|
||||
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *BaseApp) initLogger() error {
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool)
|
||||
|
||||
// Apply the min level only if it is not in develop
|
||||
// to allow printing the logs to the console.
|
||||
//
|
||||
// DB logs are still filtered but the checks for the min level are done
|
||||
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
|
||||
var minLevel slog.Level
|
||||
if app.IsDev() {
|
||||
minLevel = -9999
|
||||
} else if app.Settings() != nil {
|
||||
minLevel = slog.Level(app.Settings().Logs.MinLevel)
|
||||
}
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: minLevel,
|
||||
BatchSize: 200,
|
||||
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
|
||||
if app.IsDev() {
|
||||
printLog(log)
|
||||
|
||||
// manually check the log level and skip if necessary
|
||||
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ticker.Reset(duration)
|
||||
|
||||
return app.Settings().Logs.MaxDays > 0
|
||||
},
|
||||
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
|
||||
app.LogsDao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
model := &models.Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
// note: using pseudorandom for a slightly better performance
|
||||
model.Id = security.PseudorandomStringWithAlphabet(models.DefaultIdLength, models.DefaultIdAlphabet)
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
model.Updated = model.Created
|
||||
|
||||
if err := txDao.SaveLog(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// delete old logs
|
||||
// ---
|
||||
logsMaxDays := app.Settings().Logs.MaxDays
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
if daysDiff > float64(logsMaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Store().Set("lastLogsDeletedAt", now)
|
||||
} else {
|
||||
log.Println("Logs delete failed", deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
handler.WriteAll(ctx)
|
||||
case <-ticker.C:
|
||||
handler.WriteAll(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
app.logger = slog.New(handler)
|
||||
|
||||
app.OnTerminate().PreAdd(func(e *TerminateEvent) error {
|
||||
ticker.Stop()
|
||||
done <- true
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+87
-51
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -17,12 +17,16 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// Deprecated: Replaced with StoreKeyActiveBackup.
|
||||
const CacheKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
const StoreKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
@@ -31,6 +35,9 @@ const CacheKeyActiveBackup string = "@activeBackup"
|
||||
// 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).
|
||||
//
|
||||
@@ -39,31 +46,35 @@ const CacheKeyActiveBackup string = "@activeBackup"
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
// auto generate backup name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf(
|
||||
"pb_backup_%s.zip",
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
name = app.generateBackupName("pb_backup_")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
|
||||
// root dir entries to exclude from the backup generation
|
||||
exclude := []string{LocalBackupsDirName, LocalTempDirName}
|
||||
|
||||
// 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(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, exluding the "backups" and the temp dirs.
|
||||
//
|
||||
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
|
||||
// ---
|
||||
tempPath := filepath.Join(os.TempDir(), "pb_backup_"+security.PseudorandomString(4))
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
|
||||
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := archive.Create(app.DataDir(), tempPath, LocalBackupsDirName); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
|
||||
return archive.Create(app.DataDir(), tempPath, exclude...)
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
@@ -118,7 +129,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
//
|
||||
// 4. Move the extracted dir content to the app "pb_data".
|
||||
//
|
||||
// 5. Restart the app (on successfull app bootstap it will also remove the old pb_data).
|
||||
// 5. Restart the app (on successful app bootstap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
@@ -127,12 +138,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return errors.New("restore is not supported on windows")
|
||||
}
|
||||
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
@@ -149,7 +160,15 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
tempZip, err := os.CreateTemp(os.TempDir(), "pb_restore")
|
||||
// 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(app.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,13 +178,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// make sure that the special temp directory
|
||||
if err := os.MkdirAll(filepath.Join(app.DataDir(), LocalTempDirName), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
extractedDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "pb_restore_"+security.PseudorandomString(4))
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
@@ -179,8 +192,12 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := os.Remove(tempZip.Name()); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// root dir entries to exclude from the backup restore
|
||||
@@ -189,7 +206,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
// move the current pb_data content to a special temp location
|
||||
// that will hold the old data between dirs replace
|
||||
// (the temp dir will be automatically removed on the next app start)
|
||||
oldTempDataDir := filepath.Join(app.DataDir(), LocalTempDirName, "old_pb_data_"+security.PseudorandomString(4))
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(4))
|
||||
if err := osutils.MoveDirContent(app.DataDir(), oldTempDataDir, exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
@@ -213,8 +230,8 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// restart the app
|
||||
if err := app.Restart(); err != nil {
|
||||
if err := revertDataDirChanges(); err != nil {
|
||||
panic(err)
|
||||
if revertErr := revertDataDirChanges(); revertErr != nil {
|
||||
panic(revertErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restart the app process: %w", err)
|
||||
@@ -224,7 +241,6 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
// initAutobackupHooks registers the autobackup app serve hooks.
|
||||
// @todo add tests
|
||||
func (app *BaseApp) initAutobackupHooks() error {
|
||||
c := cron.New()
|
||||
isServe := false
|
||||
@@ -238,17 +254,16 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
}
|
||||
|
||||
c.Add("@autobackup", rawSchedule, func() {
|
||||
autoPrefix := "@auto_pb_backup_"
|
||||
const autoPrefix = "@auto_pb_backup_"
|
||||
|
||||
name := fmt.Sprintf(
|
||||
"%s%s.zip",
|
||||
autoPrefix,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
name := app.generateBackupName(autoPrefix)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to create backup",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
@@ -258,17 +273,21 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to initialize the backup filesystem",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to list autogenerated backups",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -285,9 +304,12 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
if err := fsys.Delete(f.Key); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to remove old autogenerated backup",
|
||||
slog.String("key", f.Key),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -323,3 +345,17 @@ func (app *BaseApp) initAutobackupHooks() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) generateBackupName(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"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,17 @@ func TestCreateBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// set some long app name with spaces and special characters
|
||||
app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
|
||||
|
||||
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// create with auto generated name
|
||||
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
||||
@@ -49,8 +54,8 @@ func TestCreateBackup(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedFiles := []string{
|
||||
`^pb_backup_\w+\.zip$`,
|
||||
`^pb_backup_\w+\.zip.attrs$`,
|
||||
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
|
||||
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
|
||||
"custom",
|
||||
"custom.attrs",
|
||||
}
|
||||
@@ -93,11 +98,11 @@ func TestRestoreBackup(t *testing.T) {
|
||||
}
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// missing backup
|
||||
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
// check if the new settings are saved in the db
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal("Failed to refresh the settings after delete")
|
||||
t.Fatalf("Failed to refresh the settings after delete: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+327
-43
@@ -1,20 +1,30 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
)
|
||||
|
||||
func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
IsDev: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
@@ -25,12 +35,12 @@ func TestNewBaseApp(t *testing.T) {
|
||||
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
|
||||
}
|
||||
|
||||
if !app.isDebug {
|
||||
t.Fatalf("expected isDebug true, got %v", app.isDebug)
|
||||
if !app.isDev {
|
||||
t.Fatalf("expected isDev true, got %v", app.isDev)
|
||||
}
|
||||
|
||||
if app.cache == nil {
|
||||
t.Fatal("expected cache to be set, got nil")
|
||||
if app.store == nil {
|
||||
t.Fatal("expected store to be set, got nil")
|
||||
}
|
||||
|
||||
if app.settings == nil {
|
||||
@@ -46,10 +56,9 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -57,7 +66,6 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,6 +114,14 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Expected app.settings to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if app.logger == nil {
|
||||
t.Fatal("Expected app.logger to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if _, ok := app.logger.Handler().(*logger.BatchHandler); !ok {
|
||||
t.Fatal("Expected app.logger handler to be initialized.")
|
||||
}
|
||||
|
||||
// reset
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -118,20 +134,16 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
if app.logsDao != nil {
|
||||
t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao)
|
||||
}
|
||||
|
||||
if app.settings != nil {
|
||||
t.Fatalf("Expected app.settings to be nil, got %v.", app.settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
IsDev: true,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -163,16 +175,20 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
|
||||
}
|
||||
|
||||
if app.isDebug != app.IsDebug() {
|
||||
t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug)
|
||||
if app.isDev != app.IsDev() {
|
||||
t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
|
||||
}
|
||||
|
||||
if app.settings != app.Settings() {
|
||||
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
|
||||
}
|
||||
|
||||
if app.cache != app.Cache() {
|
||||
t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache)
|
||||
if app.store != app.Store() {
|
||||
t.Fatalf("Expected app.Store %v, got %v", app.Store(), app.store)
|
||||
}
|
||||
|
||||
if app.logger != app.Logger() {
|
||||
t.Fatalf("Expected app.Logger %v, got %v", app.Logger(), app.logger)
|
||||
}
|
||||
|
||||
if app.subscriptionsBroker != app.SubscriptionsBroker() {
|
||||
@@ -185,14 +201,11 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -208,14 +221,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
@@ -238,14 +248,11 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
@@ -266,3 +273,280 @@ func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerWrites(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
threshold := 200
|
||||
|
||||
totalLogs := func(app App, t *testing.T) int {
|
||||
var total int
|
||||
|
||||
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// disabled logs retention
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
|
||||
for i := 0; i < threshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// test batch logs writes
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
|
||||
for i := 0; i < threshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
|
||||
// should trigger batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be added for the next batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
if total := totalLogs(app, t); total != threshold {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold, total)
|
||||
}
|
||||
|
||||
// wait for ~3 secs to check the timer trigger
|
||||
time.Sleep(3200 * time.Millisecond)
|
||||
if total := totalLogs(app, t); total != threshold+1 {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
level int
|
||||
// level->enabled map
|
||||
expectations map[int]bool
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: false,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app.isDev = s.isDev
|
||||
|
||||
app.Settings().Logs.MinLevel = s.level
|
||||
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh app settings: %v", err)
|
||||
}
|
||||
|
||||
for level, enabled := range s.expectations {
|
||||
if v := handler.Enabled(nil, slog.Level(level)); v != enabled {
|
||||
t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
testLogLevel := 4
|
||||
|
||||
app.Settings().Logs.MinLevel = testLogLevel
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
levels []int
|
||||
printedLevels []int
|
||||
persistedLevels []int
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
var printedLevels []int
|
||||
var persistedLevels []int
|
||||
|
||||
app.isDev = s.isDev
|
||||
|
||||
// trigger slog handler min level refresh
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// track printed logs
|
||||
originalPrintLog := printLog
|
||||
defer func() {
|
||||
printLog = originalPrintLog
|
||||
}()
|
||||
printLog = func(log *logger.Log) {
|
||||
printedLevels = append(printedLevels, int(log.Level))
|
||||
}
|
||||
|
||||
// track persisted logs
|
||||
app.LogsDao().AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
l, ok := m.(*models.Log)
|
||||
if ok {
|
||||
persistedLevels = append(persistedLevels, l.Level)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write and persist logs
|
||||
for _, l := range s.levels {
|
||||
app.Logger().Log(nil, slog.Level(l), "test")
|
||||
}
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
if err := handler.WriteAll(nil); err != nil {
|
||||
t.Fatalf("Failed to write all logs: %v", err)
|
||||
}
|
||||
|
||||
// check persisted log levels
|
||||
if len(s.persistedLevels) != len(persistedLevels) {
|
||||
t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
|
||||
}
|
||||
for _, l := range persistedLevels {
|
||||
if !list.ExistInSlice(l, s.persistedLevels) {
|
||||
t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
|
||||
}
|
||||
}
|
||||
|
||||
// check printed log levels
|
||||
if len(s.printedLevels) != len(printedLevels) {
|
||||
t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
|
||||
}
|
||||
for _, l := range printedLevels {
|
||||
if !list.ExistInSlice(l, s.printedLevels) {
|
||||
t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// note: make sure to call `defer cleanup()` when the app is no longer needed.
|
||||
func initTestBaseApp() (app *BaseApp, cleanup func(), err error) {
|
||||
testDataDir, err := os.MkdirTemp("", "test_base_app")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cleanup = func() {
|
||||
os.RemoveAll(testDataDir)
|
||||
}
|
||||
|
||||
app = NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
|
||||
initErr := func() error {
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
return fmt.Errorf("bootstrap error: %w", err)
|
||||
}
|
||||
|
||||
logsRunner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := logsRunner.Up(); err != nil {
|
||||
return fmt.Errorf("logsRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
dataRunner, err := migrate.NewRunner(app.DB(), migrations.AppMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := dataRunner.Up(); err != nil {
|
||||
return fmt.Errorf("dataRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if initErr != nil {
|
||||
cleanup()
|
||||
return nil, nil, initErr
|
||||
}
|
||||
|
||||
return app, cleanup, nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ func init() {
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -16000;
|
||||
`, nil)
|
||||
|
||||
return err
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ func connectDB(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)"
|
||||
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(-16000)"
|
||||
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
|
||||
+11
-3
@@ -1,6 +1,9 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -66,12 +70,15 @@ type BootstrapEvent struct {
|
||||
}
|
||||
|
||||
type TerminateEvent struct {
|
||||
App App
|
||||
App App
|
||||
IsRestart bool
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
App App
|
||||
Router *echo.Echo
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
@@ -116,6 +123,7 @@ type MailerAdminEvent struct {
|
||||
type RealtimeConnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var cachedColors = store.New[*color.Color](nil)
|
||||
|
||||
// getColor returns [color.Color] object and cache it (if not already).
|
||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||
cacheKey := fmt.Sprint(attrs)
|
||||
if c = cachedColors.Get(cacheKey); c == nil {
|
||||
c = color.New(attrs...)
|
||||
cachedColors.Set(cacheKey, c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// printLog prints the provided log to the stderr.
|
||||
// (note: defined as variable to overwriting in the tests)
|
||||
var printLog = func(log *logger.Log) {
|
||||
var str strings.Builder
|
||||
|
||||
switch log.Level {
|
||||
case slog.LevelDebug:
|
||||
str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelInfo:
|
||||
str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelWarn:
|
||||
str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
|
||||
str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
|
||||
case slog.LevelError:
|
||||
str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
|
||||
str.WriteString(getColor(color.FgRed).Sprint(log.Message))
|
||||
default:
|
||||
str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
|
||||
str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
|
||||
}
|
||||
|
||||
str.WriteString("\n")
|
||||
|
||||
if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
|
||||
padding := 0
|
||||
keys := []string{"error", "details"}
|
||||
for _, k := range keys {
|
||||
if v := log.Data[k]; v != nil {
|
||||
str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
|
||||
str.WriteString("\n")
|
||||
padding += 3
|
||||
}
|
||||
}
|
||||
} else if len(log.Data) > 0 {
|
||||
str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
|
||||
str.WriteString("\n")
|
||||
}
|
||||
|
||||
fmt.Print(str.String())
|
||||
}
|
||||
+2
-2
@@ -46,9 +46,9 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindAdminByToken finds the admin associated with the provided JWT token.
|
||||
// FindAdminByToken finds the admin associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
// Returns an error if the JWT is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
// @todo consider caching the unverified claims
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
|
||||
+126
-93
@@ -5,6 +5,7 @@ package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -29,7 +30,8 @@ func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
//
|
||||
// You can think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
@@ -43,12 +45,13 @@ type Dao struct {
|
||||
// This field has no effect if an explicit query context is already specified.
|
||||
ModelQueryTimeout time.Duration
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
// write hooks
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
}
|
||||
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
@@ -81,6 +84,21 @@ func (dao *Dao) Clone() *Dao {
|
||||
return &clone
|
||||
}
|
||||
|
||||
// WithoutHooks returns a new Dao with the same configuration options
|
||||
// as the current one, but without create/update/delete hooks.
|
||||
func (dao *Dao) WithoutHooks() *Dao {
|
||||
new := dao.Clone()
|
||||
|
||||
new.BeforeCreateFunc = nil
|
||||
new.AfterCreateFunc = nil
|
||||
new.BeforeUpdateFunc = nil
|
||||
new.AfterUpdateFunc = nil
|
||||
new.BeforeDeleteFunc = nil
|
||||
new.AfterDeleteFunc = nil
|
||||
|
||||
return new
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
@@ -134,56 +152,69 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
txDao := New(tx)
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m)
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m)
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m)
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fn(txDao)
|
||||
})
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
if txError != nil {
|
||||
return txError
|
||||
}
|
||||
|
||||
return txError
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
var errs []error
|
||||
for _, call := range afterCalls {
|
||||
var err error
|
||||
switch call.Action {
|
||||
case "create":
|
||||
err = dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
err = dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
err = dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
@@ -196,21 +227,23 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
}
|
||||
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
action := func() error {
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
return retryDao.BeforeDeleteFunc(retryDao, m, action)
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,35 +274,35 @@ func (dao *Dao) update(m models.Model) error {
|
||||
|
||||
m.RefreshUpdated()
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
return dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
if err := dao.BeforeUpdateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeUpdateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) create(m models.Model) error {
|
||||
@@ -289,36 +322,36 @@ func (dao *Dao) create(m models.Model) error {
|
||||
m.RefreshUpdated()
|
||||
}
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
return dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
if err := dao.BeforeCreateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeCreateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
|
||||
|
||||
+9
-1
@@ -2,6 +2,9 @@ package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,9 +26,14 @@ func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
|
||||
q.WithContext(cancelCtx)
|
||||
}
|
||||
|
||||
return baseLockRetry(func(attempt int) error {
|
||||
execErr := baseLockRetry(func(attempt int) error {
|
||||
return op()
|
||||
}, maxRetries)
|
||||
if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
|
||||
execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
|
||||
}
|
||||
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+131
-47
@@ -49,33 +49,37 @@ func TestDaoClone(t *testing.T) {
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := dao.Clone()
|
||||
clone.MaxLockRetries = 3
|
||||
clone.ModelQueryTimeout = 4
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["NewAfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.MaxLockRetries == clone.MaxLockRetries {
|
||||
@@ -86,16 +90,18 @@ func TestDaoClone(t *testing.T) {
|
||||
t.Fatal("Expected different ModelQueryTimeout")
|
||||
}
|
||||
|
||||
emptyAction := func() error { return nil }
|
||||
|
||||
// trigger hooks
|
||||
dao.BeforeDeleteFunc(nil, nil)
|
||||
dao.BeforeUpdateFunc(nil, nil)
|
||||
dao.BeforeCreateFunc(nil, nil)
|
||||
dao.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
dao.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
dao.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
dao.AfterDeleteFunc(nil, nil)
|
||||
dao.AfterUpdateFunc(nil, nil)
|
||||
dao.AfterCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil)
|
||||
clone.BeforeUpdateFunc(nil, nil)
|
||||
clone.BeforeCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
clone.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
clone.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
clone.AfterDeleteFunc(nil, nil)
|
||||
clone.AfterUpdateFunc(nil, nil)
|
||||
clone.AfterCreateFunc(nil, nil)
|
||||
@@ -120,6 +126,75 @@ func TestDaoClone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoWithoutHooks(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
hookCalls := map[string]int{}
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
new := dao.WithoutHooks()
|
||||
|
||||
if new.MaxLockRetries != dao.MaxLockRetries {
|
||||
t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
|
||||
}
|
||||
|
||||
if new.ModelQueryTimeout != dao.ModelQueryTimeout {
|
||||
t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
|
||||
}
|
||||
|
||||
if new.BeforeDeleteFunc != nil {
|
||||
t.Fatal("Expected BeforeDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeUpdateFunc != nil {
|
||||
t.Fatal("Expected BeforeUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeCreateFunc != nil {
|
||||
t.Fatal("Expected BeforeCreateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterDeleteFunc != nil {
|
||||
t.Fatal("Expected AfterDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterUpdateFunc != nil {
|
||||
t.Fatal("Expected AfterUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterCreateFunc != nil {
|
||||
t.Fatal("Expected AfterCreateFunc to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -415,12 +490,13 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterCreateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
@@ -441,7 +517,7 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -473,12 +549,13 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterUpdateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
@@ -498,7 +575,7 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -524,12 +601,13 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterDeleteHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
@@ -550,7 +628,7 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -577,13 +655,13 @@ func TestDaoBeforeHooksError(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_create")
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_update")
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_delete")
|
||||
}
|
||||
|
||||
@@ -622,27 +700,30 @@ func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
@@ -710,27 +791,30 @@ func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
|
||||
@@ -378,6 +378,12 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
|
||||
}
|
||||
}
|
||||
|
||||
// wrap view query if necessary
|
||||
query, err = txDao.normalizeViewQueryId(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize view query id: %w", err)
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
if err := txDao.SaveView(newCollection.Name, query); err != nil {
|
||||
return err
|
||||
@@ -389,6 +395,54 @@ func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collecti
|
||||
})
|
||||
}
|
||||
|
||||
// @todo consider removing once custom id types are supported
|
||||
//
|
||||
// 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
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110).
|
||||
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
|
||||
query = strings.Trim(strings.TrimSpace(query), ";")
|
||||
|
||||
parsed, err := dao.parseQueryToFields(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
needWrapping := true
|
||||
|
||||
idField := parsed[schema.FieldNameId]
|
||||
if idField != nil && idField.field != nil &&
|
||||
idField.field.Type != schema.FieldTypeJson &&
|
||||
idField.field.Type != schema.FieldTypeNumber &&
|
||||
idField.field.Type != schema.FieldTypeBool {
|
||||
needWrapping = false
|
||||
}
|
||||
|
||||
if !needWrapping {
|
||||
return query, nil // no changes needed
|
||||
}
|
||||
|
||||
// raw parse to preserve the columns order
|
||||
rawParsed := new(identifiersParser)
|
||||
if err := rawParsed.parse(query); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(rawParsed.columns))
|
||||
for _, col := range rawParsed.columns {
|
||||
if col.alias == schema.FieldNameId {
|
||||
columns = append(columns, fmt.Sprintf("cast([[%s]] as text) [[%s]]", col.alias, col.alias))
|
||||
} else {
|
||||
columns = append(columns, "[["+col.alias+"]]")
|
||||
}
|
||||
}
|
||||
|
||||
query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
|
||||
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
|
||||
+113
-3
@@ -210,11 +210,12 @@ func TestDeleteCollection(t *testing.T) {
|
||||
{colUnsaved, true},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{colBase, true}, // depend on view1, view2 and view2
|
||||
{colView1, true}, // view2 depend on it
|
||||
{colView2, false},
|
||||
{colView1, false}, // no longer has dependent collections
|
||||
{colBase, false},
|
||||
{colAuth, false}, // should delete also its related external auths
|
||||
{colBase, false}, // no longer has dependent views
|
||||
{colAuth, false}, // should delete also its related external auths
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -393,8 +394,117 @@ func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCollectionViewWrapping(t *testing.T) {
|
||||
viewName := "test_wrapping"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"no wrapping - text field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - id field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - relation field",
|
||||
"select rel_one as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - select field",
|
||||
"select select_many as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - email field",
|
||||
"select email as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - datetime field",
|
||||
"select datetime as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - url field",
|
||||
"select url as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field",
|
||||
"select bool as id, text as txt, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field (different order)",
|
||||
"select text as txt, url, bool as id from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,cast(`id` as text) `id` FROM (select text as txt, url, bool as id from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - json field",
|
||||
"select json as id, text, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
"select 1 as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select 1 as id))",
|
||||
},
|
||||
{
|
||||
"wrapping - expresion",
|
||||
"select ('test') as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select ('test') as id))",
|
||||
},
|
||||
{
|
||||
"no wrapping - cast as text",
|
||||
"select cast('test' as text) as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: viewName,
|
||||
Type: models.CollectionTypeView,
|
||||
Options: types.JsonMap{
|
||||
"query": s.query,
|
||||
},
|
||||
}
|
||||
|
||||
err := app.Dao().SaveCollection(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sql string
|
||||
|
||||
rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
Bind(dbx.Params{"name": viewName}).
|
||||
Row(&sql)
|
||||
if rowErr != nil {
|
||||
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
|
||||
}
|
||||
|
||||
if sql != s.expected {
|
||||
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCollections(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
+18
-21
@@ -32,27 +32,6 @@ func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*mode
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByProvider returns the first available
|
||||
// ExternalAuth model for the specified provider and providerId.
|
||||
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(dbx.HashExp{
|
||||
"provider": provider,
|
||||
"providerId": providerId,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByRecordAndProvider returns the first available
|
||||
// ExternalAuth model for the specified record data and provider.
|
||||
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
|
||||
@@ -74,6 +53,24 @@ func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, p
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindFirstExternalAuthByExpr returns the first available
|
||||
// ExternalAuth model that satisfies the non-nil expression.
|
||||
func (dao *Dao) FindFirstExternalAuthByExpr(expr dbx.Expression) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(expr).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// SaveExternalAuth upserts the provided ExternalAuth model.
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
|
||||
+14
-11
@@ -3,6 +3,7 @@ package daos_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
@@ -56,25 +57,23 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
func TestFindFirstExternalAuthByExpr(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
provider string
|
||||
providerId string
|
||||
expr dbx.Expression
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
{dbx.HashExp{"provider": "github", "providerId": ""}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id1"}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id2"}, ""},
|
||||
{dbx.HashExp{"provider": "google", "providerId": "test123"}, "clmflokuq1xl341"},
|
||||
{dbx.HashExp{"provider": "gitlab", "providerId": "test123"}, "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
|
||||
auth, err := app.Dao().FindFirstExternalAuthByExpr(s.expr)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
@@ -147,7 +146,11 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if it was really saved
|
||||
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
|
||||
foundAuth, err := app.Dao().FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": "v851q4r790rhknl",
|
||||
"provider": "test",
|
||||
"providerId": "test_id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// LogQuery returns a new Log select query.
|
||||
func (dao *Dao) LogQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Log{})
|
||||
}
|
||||
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
func (dao *Dao) FindLogById(id string) (*models.Log, error) {
|
||||
model := &models.Log{}
|
||||
|
||||
err := dao.LogQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type LogsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// LogsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
result := []*LogsStatsItem{}
|
||||
|
||||
query := dao.LogQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldLogs(createdBefore time.Time) error {
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveLog upserts the provided Log model.
|
||||
func (dao *Dao) SaveLog(log *models.Log) error {
|
||||
return dao.Save(log)
|
||||
}
|
||||
@@ -11,23 +11,23 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRequestQuery(t *testing.T) {
|
||||
func TestLogQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_requests}}.* FROM `_requests`"
|
||||
expected := "SELECT {{_logs}}.* FROM `_logs`"
|
||||
|
||||
sql := app.Dao().RequestQuery().Build().SQL()
|
||||
sql := app.Dao().LogQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRequestById(t *testing.T) {
|
||||
func TestFindLogById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
@@ -40,7 +40,7 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
admin, err := app.LogsDao().FindRequestById(scenario.id)
|
||||
admin, err := app.LogsDao().FindLogById(scenario.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -53,17 +53,17 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
|
||||
|
||||
now := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
|
||||
result, err := app.LogsDao().RequestsStats(exp)
|
||||
result, err := app.LogsDao().LogsStats(exp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,20 +74,20 @@ func TestRequestsStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldRequests(t *testing.T) {
|
||||
func TestDeleteOldLogs(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
date string
|
||||
expectedTotal int
|
||||
}{
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no logs to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more logs should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -96,53 +96,53 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
t.Errorf("(%d) Date error %v", i, dateErr)
|
||||
}
|
||||
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(date)
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(date)
|
||||
if deleteErr != nil {
|
||||
t.Errorf("(%d) Delete error %v", i, deleteErr)
|
||||
}
|
||||
|
||||
// check total remaining requests
|
||||
// check total remaining logs
|
||||
var total int
|
||||
countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total)
|
||||
countErr := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if countErr != nil {
|
||||
t.Errorf("(%d) Count error %v", i, countErr)
|
||||
}
|
||||
|
||||
if total != scenario.expectedTotal {
|
||||
t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total)
|
||||
t.Errorf("(%d) Expected %d remaining logs, got %d", i, scenario.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRequest(t *testing.T) {
|
||||
func TestSaveLog(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
// create new request
|
||||
newRequest := &models.Request{}
|
||||
newRequest.Method = "get"
|
||||
newRequest.Meta = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveRequest(newRequest)
|
||||
// create new log
|
||||
newLog := &models.Log{}
|
||||
newLog.Level = -4
|
||||
newLog.Data = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveLog(newLog)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
|
||||
// check if it was really created
|
||||
existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id)
|
||||
existingLog, fetchErr := app.LogsDao().FindLogById(newLog.Id)
|
||||
if fetchErr != nil {
|
||||
t.Fatal(fetchErr)
|
||||
}
|
||||
|
||||
existingRequest.Method = "post"
|
||||
updateErr := app.LogsDao().SaveRequest(existingRequest)
|
||||
existingLog.Level = 4
|
||||
updateErr := app.LogsDao().SaveLog(existingLog)
|
||||
if updateErr != nil {
|
||||
t.Fatal(updateErr)
|
||||
}
|
||||
// refresh instance to check if it was really updated
|
||||
existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id)
|
||||
if existingRequest.Method != "post" {
|
||||
t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method)
|
||||
existingLog, _ = app.LogsDao().FindLogById(existingLog.Id)
|
||||
if existingLog.Level != 4 {
|
||||
t.Fatalf("Expected log level to be %d, got %d", 4, existingLog.Level)
|
||||
}
|
||||
}
|
||||
+269
-75
@@ -1,6 +1,8 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -8,86 +10,123 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
tableName := collection.Name
|
||||
// RecordQuery returns a new Record select query from a collection model, id or name.
|
||||
//
|
||||
// In case a collection id or name is provided and that collection doesn't
|
||||
// actually exists, the generated query will be created with a cancelled context
|
||||
// and will fail once an executor (Row(), One(), All(), etc.) is called.
|
||||
func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
|
||||
var tableName string
|
||||
var collection *models.Collection
|
||||
var collectionErr error
|
||||
switch c := collectionModelOrIdentifier.(type) {
|
||||
case *models.Collection:
|
||||
collection = c
|
||||
tableName = collection.Name
|
||||
case models.Collection:
|
||||
collection = &c
|
||||
tableName = collection.Name
|
||||
case string:
|
||||
collection, collectionErr = dao.FindCollectionByNameOrId(c)
|
||||
if collection != nil {
|
||||
tableName = collection.Name
|
||||
} else {
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__missing_" + c
|
||||
}
|
||||
default:
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__invalidCollectionModelOrIdentifier"
|
||||
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
|
||||
}
|
||||
|
||||
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
|
||||
|
||||
return dao.DB().
|
||||
Select(selectCols).
|
||||
From(tableName).
|
||||
WithBuildHook(func(query *dbx.Query) {
|
||||
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
query := dao.DB().Select(selectCols).From(tableName)
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
// in case of an error attach a new context and cancel it immediately with the error
|
||||
if collectionErr != nil {
|
||||
// @todo consider changing to WithCancelCause when upgrading
|
||||
// the min Go requirement to 1.20, so that we can pass the error
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
query.WithContext(ctx)
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
default:
|
||||
return query.WithBuildHook(func(q *dbx.Query) {
|
||||
q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = records
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
default:
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
*v = records
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
@@ -158,6 +197,8 @@ func (dao *Dao) FindRecordsByIds(
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
|
||||
//
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
//
|
||||
// Returns all collection records if no expressions are provided.
|
||||
@@ -170,12 +211,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
query := dao.RecordQuery(collectionNameOrId)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
@@ -200,14 +236,9 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
key string,
|
||||
value any,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
err := dao.RecordQuery(collectionNameOrId).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
@@ -218,6 +249,113 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindRecordsByFilter returns limit number of records matching the
|
||||
// provided string filter.
|
||||
//
|
||||
// NB! Use the last "params" argument to bind untrusted user variables!
|
||||
//
|
||||
// The sort argument is optional and can be empty string OR the same format
|
||||
// used in the web APIs, eg. "-created,title".
|
||||
//
|
||||
// If the limit argument is <= 0, no limit is applied to the query and
|
||||
// all matching records are returned.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindRecordsByFilter(
|
||||
// "posts",
|
||||
// "title ~ {:title} && visible = {:visible}",
|
||||
// "-created",
|
||||
// 10,
|
||||
// 0,
|
||||
// dbx.Params{"title": "lorem ipsum", "visible": true}
|
||||
// )
|
||||
func (dao *Dao) FindRecordsByFilter(
|
||||
collectionNameOrId string,
|
||||
filter string,
|
||||
sort string,
|
||||
limit int,
|
||||
offset int,
|
||||
params ...dbx.Params,
|
||||
) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := dao.RecordQuery(collection)
|
||||
|
||||
// build a fields resolver and attach the generated conditions to the query
|
||||
// ---
|
||||
resolver := resolvers.NewRecordFieldResolver(
|
||||
dao,
|
||||
collection, // the base collection
|
||||
nil, // no request data
|
||||
true, // allow searching hidden/protected fields like "email"
|
||||
)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
|
||||
if err != nil || expr == nil {
|
||||
return nil, errors.New("invalid or empty filter expression")
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
if sort != "" {
|
||||
for _, sortField := range search.ParseSortFromString(sort) {
|
||||
expr, err := sortField.BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != "" {
|
||||
q.AndOrderBy(expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
|
||||
// ---
|
||||
|
||||
if offset > 0 {
|
||||
q.Offset(int64(offset))
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
q.Limit(int64(limit))
|
||||
}
|
||||
|
||||
records := []*models.Record{}
|
||||
|
||||
if err := q.All(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// FindFirstRecordByFilter returns the first available record matching the provided filter.
|
||||
//
|
||||
// NB! Use the last params argument to bind untrusted user variables!
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
|
||||
func (dao *Dao) FindFirstRecordByFilter(
|
||||
collectionNameOrId string,
|
||||
filter string,
|
||||
params ...dbx.Params,
|
||||
) (*models.Record, error) {
|
||||
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1, 0, params...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return result[0], nil
|
||||
}
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
@@ -271,9 +409,9 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
|
||||
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
@@ -386,6 +524,62 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
return username
|
||||
}
|
||||
|
||||
// CanAccessRecord checks if a record is allowed to be accessed by the
|
||||
// specified requestInfo and accessRule.
|
||||
//
|
||||
// Rule and db checks are ignored in case requestInfo.Admin is set.
|
||||
//
|
||||
// The returned error indicate that something unexpected happened during
|
||||
// the check (eg. invalid rule or db error).
|
||||
//
|
||||
// The method always return false on invalid access rule or db error.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// requestInfo := apis.RequestInfo(c /* echo.Context */)
|
||||
// record, _ := dao.FindRecordById("example", "RECORD_ID")
|
||||
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
|
||||
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
|
||||
//
|
||||
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
|
||||
func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
|
||||
if requestInfo.Admin != nil {
|
||||
// admins can access everything
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if *accessRule == "" {
|
||||
// empty public rule, aka. everyone can access
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
query := dao.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
|
||||
|
||||
// parse and apply the access rule filter
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// SaveRecord persists the provided Record model in the database.
|
||||
//
|
||||
// If record.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
|
||||
+15
-7
@@ -1,7 +1,6 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -24,21 +23,27 @@ type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]
|
||||
|
||||
// ExpandRecord expands the relations of a single Record model.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
|
||||
}
|
||||
|
||||
// ExpandRecords expands the relations of the provided Record models list.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
normalized := normalizeExpands(expands)
|
||||
|
||||
failed := map[string]error{}
|
||||
|
||||
for _, expand := range normalized {
|
||||
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
|
||||
if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
|
||||
failed[expand] = err
|
||||
}
|
||||
}
|
||||
@@ -49,13 +54,16 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
// notes:
|
||||
// - fetchFunc must be non-nil func
|
||||
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
|
||||
// - all records are expected to be from the same collection
|
||||
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
|
||||
// - indirect expands are supported only with single relation fields
|
||||
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
|
||||
if fetchFunc == nil {
|
||||
return errors.New("Relation records fetchFunc is not set.")
|
||||
// load a default fetchFunc
|
||||
fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
return dao.FindRecordsByIds(relCollection.Id, relIds)
|
||||
}
|
||||
}
|
||||
|
||||
if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
|
||||
|
||||
+26
-18
@@ -48,15 +48,6 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
@@ -101,6 +92,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
1,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
@@ -132,6 +136,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
@@ -228,15 +245,6 @@ func TestExpandRecord(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
|
||||
+64
-76
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -159,10 +158,6 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
return err
|
||||
}
|
||||
|
||||
if err := txDao.syncRelationDisplayFieldsChanges(newCollection, renamedFieldNames, deletedFieldNames); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txDao.createCollectionIndexes(newCollection)
|
||||
})
|
||||
}
|
||||
@@ -173,10 +168,23 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// temporary disable the schema error checks to prevent view and trigger errors
|
||||
// when "altering" (aka. deleting and recreating) the non-normalized columns
|
||||
if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
// executed with defer to make sure that the pragma is always reverted
|
||||
// in case of an error and when nested transactions are used
|
||||
defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
for _, newField := range newCollection.Schema.Fields() {
|
||||
oldField := oldCollection.Schema.GetFieldById(newField.Id)
|
||||
if oldField == nil {
|
||||
continue
|
||||
// allow to continue even if there is no old field for the cases
|
||||
// when a new field is added and there are already inserted data
|
||||
var isOldMultiple bool
|
||||
if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
}
|
||||
|
||||
var isNewMultiple bool
|
||||
@@ -184,20 +192,30 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
isNewMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
var isOldMultiple bool
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
if isOldMultiple == isNewMultiple {
|
||||
continue // no change
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
// update the column definition by:
|
||||
// 1. inserting a new column with the new definition
|
||||
// 2. copy normalized values from the original column to the new one
|
||||
// 3. drop the original column
|
||||
// 4. rename the new column to the original column
|
||||
// -------------------------------------------------------
|
||||
|
||||
originalName := newField.Name
|
||||
tempName := "_" + newField.Name + security.PseudorandomString(5)
|
||||
|
||||
_, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var copyQuery *dbx.Query
|
||||
|
||||
if !isOldMultiple && isNewMultiple {
|
||||
// single -> multiple (convert to array)
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
@@ -212,19 +230,19 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
} else {
|
||||
// multiple -> single (keep only the last element)
|
||||
//
|
||||
// note: for file fields the actual files are not deleted
|
||||
// allowing additional custom handling via migration.
|
||||
updateQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
// note: for file fields the actual file objects are not
|
||||
// deleted allowing additional custom handling via migration
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
@@ -239,68 +257,38 @@ func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollecti
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
newField.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
// copy the normalized values
|
||||
if _, err := copyQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop the original column
|
||||
if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the new column back to the original
|
||||
if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// revert the pragma and reload the schema
|
||||
_, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
return revertErr
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncRelationDisplayFieldsChanges(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
}
|
||||
|
||||
refs, err := dao.FindCollectionReferences(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for refCollection, refFields := range refs {
|
||||
for _, refField := range refFields {
|
||||
options, _ := refField.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove deleted (if any)
|
||||
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
|
||||
|
||||
for old, new := range renamedFieldNames {
|
||||
for i, name := range newDisplayFields {
|
||||
if name == old {
|
||||
newDisplayFields[i] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// has changes
|
||||
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
|
||||
options.DisplayFields = newDisplayFields
|
||||
|
||||
// direct collection save to prevent self-referencing
|
||||
// recursion and unnecessary records table sync checks
|
||||
if err := dao.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
|
||||
if collection.IsView() {
|
||||
return nil // views don't have indexes
|
||||
|
||||
+117
-57
@@ -171,89 +171,149 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
opt := relManyField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
{
|
||||
// new multivaluer field to check whether the array normalization
|
||||
// will be applied for already inserted data
|
||||
collection.Schema.AddField(&schema.SchemaField{
|
||||
Name: "new_multiple",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
Values: []string{"a", "b", "c"},
|
||||
MaxSelect: 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type expectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
// ensures that the writable schema was reverted to its expected default
|
||||
var writableSchema bool
|
||||
app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
|
||||
if writableSchema == true {
|
||||
t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
// check whether the columns DEFAULT definition was updated
|
||||
// ---------------------------------------------------------------
|
||||
tableInfo, err := app.Dao().TableInfo(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tableInfoExpectations := map[string]string{
|
||||
"select_one": `'[]'`,
|
||||
"select_many": `''`,
|
||||
"file_one": `'[]'`,
|
||||
"file_many": `''`,
|
||||
"rel_one": `'[]'`,
|
||||
"rel_many": `''`,
|
||||
"new_multiple": `'[]'`,
|
||||
}
|
||||
for col, dflt := range tableInfoExpectations {
|
||||
t.Run("check default for "+col, func(t *testing.T) {
|
||||
var row *models.TableInfoRow
|
||||
for _, r := range tableInfo {
|
||||
if r.Name == col {
|
||||
row = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatalf("Missing info for column %q", col)
|
||||
}
|
||||
|
||||
if v := row.DefaultValue.String(); v != dflt {
|
||||
t.Fatalf("Expected default value %q, got %q", dflt, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// check whether the values were normalized
|
||||
// ---------------------------------------------------------------
|
||||
type fieldsExpectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
NewMultiple string `db:"new_multiple"`
|
||||
}
|
||||
|
||||
fieldsScenarios := []struct {
|
||||
recordId string
|
||||
expected expectation
|
||||
expected fieldsExpectation
|
||||
}{
|
||||
{
|
||||
"imy661ixudk5izi",
|
||||
expectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
fieldsExpectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"al1h9ijdeojtsjy",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"84nmscqy84lsi1t",
|
||||
expectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result := new(expectation)
|
||||
for _, s := range fieldsScenarios {
|
||||
t.Run("check fields for record "+s.recordId, func(t *testing.T) {
|
||||
result := new(fieldsExpectation)
|
||||
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to load record: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
"new_multiple",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load record: %v", err)
|
||||
}
|
||||
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode result: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode result: %v", err)
|
||||
}
|
||||
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to encode expectation: %v", s.recordId, err)
|
||||
continue
|
||||
}
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode expectation: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Errorf("[%s] Expected \n%s, \ngot \n%s", s.recordId, encodedExpectation, encodedResult)
|
||||
}
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+414
-6
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -28,11 +27,33 @@ func TestRecordQuery(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
scenarios := []struct {
|
||||
name any
|
||||
collection any
|
||||
expectedTotal int
|
||||
expectError bool
|
||||
}{
|
||||
{"with nil value", nil, 0, true},
|
||||
{"with invalid or missing collection id/name", "missing", 0, true},
|
||||
{"with pointer model", collection, 3, false},
|
||||
{"with value model", *collection, 3, false},
|
||||
{"with name", "demo1", 3, false},
|
||||
{"with id", "wsmn24bux7wo113", 3, false},
|
||||
}
|
||||
|
||||
sql := app.Dao().RecordQuery(collection).Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
for _, s := range scenarios {
|
||||
var records []*models.Record
|
||||
err := app.Dao().RecordQuery(s.collection).All(&records)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if total := len(records); total != s.expectedTotal {
|
||||
t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +426,393 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordsByFilter(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
sort string
|
||||
limit int
|
||||
offset int
|
||||
params []dbx.Params
|
||||
expectError bool
|
||||
expectRecordIds []string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"simple filter",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
},
|
||||
{
|
||||
"multi-condition filter with sort",
|
||||
"demo2",
|
||||
"id != '' && active=true",
|
||||
"-created,title",
|
||||
-1, // should behave the same as 0
|
||||
0,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"0yxhwia2amd8gec",
|
||||
"achvryl401bhse3",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with limit and offset",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"title",
|
||||
2,
|
||||
1,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with placeholder params",
|
||||
"demo2",
|
||||
"active = {:active}",
|
||||
"",
|
||||
10,
|
||||
0,
|
||||
[]dbx.Params{{"active": false}},
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
records, err := app.Dao().FindRecordsByFilter(
|
||||
s.collectionIdOrName,
|
||||
s.filter,
|
||||
s.sort,
|
||||
s.limit,
|
||||
s.offset,
|
||||
s.params...,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) != len(s.expectRecordIds) {
|
||||
t.Fatalf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
|
||||
}
|
||||
|
||||
for i, id := range s.expectRecordIds {
|
||||
if id != records[i].Id {
|
||||
t.Fatalf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByFilter(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
params []dbx.Params
|
||||
expectError bool
|
||||
expectRecordId string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter but no matches",
|
||||
"demo2",
|
||||
"id = 'test'",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter and multiple matches",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
nil,
|
||||
false,
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
{
|
||||
"with placeholder params",
|
||||
"demo2",
|
||||
"active = {:active}",
|
||||
[]dbx.Params{{"active": false}},
|
||||
false,
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Id != s.expectRecordId {
|
||||
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAccessRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
requestInfo *models.RequestInfo
|
||||
rule *string
|
||||
expected bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"as admin with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with non-empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id = ''"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as guest with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("@request.auth.id != ''"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as auth record with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 2},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
|
||||
|
||||
if result != s.expected {
|
||||
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecordValueUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RequestQuery returns a new Request logs select query.
|
||||
func (dao *Dao) RequestQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Request{})
|
||||
}
|
||||
|
||||
// FindRequestById finds a single Request log by its id.
|
||||
func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
|
||||
model := &models.Request{}
|
||||
|
||||
err := dao.RequestQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type RequestsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// RequestsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
|
||||
result := []*RequestsStatsItem{}
|
||||
|
||||
query := dao.RequestQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldRequests delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
|
||||
m := models.Request{}
|
||||
tableName := m.TableName()
|
||||
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.NonconcurrentDB().Delete(tableName, expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveRequest upserts the provided Request model.
|
||||
func (dao *Dao) SaveRequest(request *models.Request) error {
|
||||
return dao.Save(request)
|
||||
}
|
||||
+53
-16
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
trimmed := strings.Trim(selectQuery, ";")
|
||||
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
|
||||
// try to eagerly detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(trimmed)
|
||||
tk := tokenizer.NewFromString(selectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
@@ -56,7 +56,7 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
//
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution.
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
|
||||
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -232,9 +232,14 @@ func defaultViewField(name string) *schema.SchemaField {
|
||||
return &schema.SchemaField{
|
||||
Name: name,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 1, // the size doesn't matter in this case
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
|
||||
|
||||
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
|
||||
p := new(identifiersParser)
|
||||
if err := p.parse(selectQuery); err != nil {
|
||||
@@ -257,15 +262,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
for _, col := range p.columns {
|
||||
colLower := strings.ToLower(col.original)
|
||||
|
||||
// numeric expression cast
|
||||
if strings.Contains(colLower, "(") &&
|
||||
(strings.HasPrefix(colLower, "count(") ||
|
||||
strings.HasPrefix(colLower, "total(") ||
|
||||
strings.Contains(colLower, " as numeric") ||
|
||||
strings.Contains(colLower, " as real") ||
|
||||
strings.Contains(colLower, " as int") ||
|
||||
strings.Contains(colLower, " as integer") ||
|
||||
strings.Contains(colLower, " as decimal")) {
|
||||
// numeric aggregations
|
||||
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
@@ -275,6 +273,38 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
continue
|
||||
}
|
||||
|
||||
castMatch := castRegex.FindStringSubmatch(colLower)
|
||||
|
||||
// numeric casts
|
||||
if len(castMatch) == 2 {
|
||||
switch castMatch[1] {
|
||||
case "real", "integer", "int", "decimal", "numeric":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "text":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "boolean", "bool":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(col.original, ".")
|
||||
|
||||
var fieldName string
|
||||
@@ -431,7 +461,7 @@ type identifiersParser struct {
|
||||
}
|
||||
|
||||
func (p *identifiersParser) parse(selectQuery string) error {
|
||||
str := strings.Trim(selectQuery, ";")
|
||||
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
|
||||
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
|
||||
str = commentsReplaceRegex.ReplaceAllString(str, "")
|
||||
@@ -572,13 +602,20 @@ func identifierFromParts(parts []string) (identifier, error) {
|
||||
}
|
||||
|
||||
result.original = trimRawIdentifier(result.original)
|
||||
result.alias = trimRawIdentifier(result.alias)
|
||||
|
||||
// we trim the single quote even though it is not a valid column quote character
|
||||
// because SQLite allows it if the context expects an identifier and not string literal
|
||||
// (https://www.sqlite.org/lang_keywords.html)
|
||||
result.alias = trimRawIdentifier(result.alias, "'")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trimRawIdentifier(rawIdentifier string) string {
|
||||
const trimChars = "`\"[];"
|
||||
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
|
||||
trimChars := "`\"[];"
|
||||
if len(extraTrimChars) > 0 {
|
||||
trimChars += strings.Join(extraTrimChars, "")
|
||||
}
|
||||
|
||||
parts := strings.Split(rawIdentifier, ".")
|
||||
|
||||
|
||||
+48
-41
@@ -147,34 +147,33 @@ func TestSaveView(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
t.Run(s.scenarioName, func(t *testing.T) {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
@@ -272,24 +271,26 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"datetime",
|
||||
"json",
|
||||
"rel_one",
|
||||
"rel_many"
|
||||
"rel_many",
|
||||
'single_quoted_custom_literal' as 'single_quoted_column'
|
||||
from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"single_quoted_column": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -330,7 +331,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with numeric casts",
|
||||
"query with casts",
|
||||
`select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
@@ -339,6 +340,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric,
|
||||
cast(a.id as text) cast_text,
|
||||
cast(a.id as bool) cast_bool,
|
||||
cast(a.id as boolean) cast_boolean,
|
||||
avg(a.id) avg,
|
||||
sum(a.id) sum,
|
||||
total(a.id) total,
|
||||
@@ -354,6 +358,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"cast_real": schema.FieldTypeNumber,
|
||||
"cast_decimal": schema.FieldTypeNumber,
|
||||
"cast_numeric": schema.FieldTypeNumber,
|
||||
"cast_text": schema.FieldTypeText,
|
||||
"cast_bool": schema.FieldTypeBool,
|
||||
"cast_boolean": schema.FieldTypeBool,
|
||||
// json because they are nullable
|
||||
"sum": schema.FieldTypeJson,
|
||||
"avg": schema.FieldTypeJson,
|
||||
|
||||
+34
-6
@@ -22,6 +22,30 @@ func main() {
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var hooksDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&hooksDir,
|
||||
"hooksDir",
|
||||
"",
|
||||
"the directory with the JS app hooks",
|
||||
)
|
||||
|
||||
var hooksWatch bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&hooksWatch,
|
||||
"hooksWatch",
|
||||
true,
|
||||
"auto restart the app on pb_hooks file change",
|
||||
)
|
||||
|
||||
var hooksPool int
|
||||
app.RootCmd.PersistentFlags().IntVar(
|
||||
&hooksPool,
|
||||
"hooksPool",
|
||||
25,
|
||||
"the total prewarm goja.Runtime instances for the JS app hooks execution",
|
||||
)
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&migrationsDir,
|
||||
@@ -68,22 +92,25 @@ func main() {
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
// load jsvm (hooks and migrations)
|
||||
jsvm.MustRegister(app, jsvm.Config{
|
||||
MigrationsDir: migrationsDir,
|
||||
HooksDir: hooksDir,
|
||||
HooksWatch: hooksWatch,
|
||||
HooksPoolSize: hooksPool,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
// GitHub selfupdate
|
||||
ghupdate.MustRegister(app, app.RootCmd, nil)
|
||||
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
return nil
|
||||
})
|
||||
@@ -105,5 +132,6 @@ func defaultPublicDir() string {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -66,7 +67,7 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -75,14 +76,14 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return runInterceptors(admin, func(m *models.Admin) error {
|
||||
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
m.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
if interceptorLastResetSentAt.String() != admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
|
||||
|
||||
// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
|
||||
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
|
||||
//
|
||||
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
|
||||
type AppleClientSecretCreate struct {
|
||||
@@ -33,7 +33,7 @@ type AppleClientSecretCreate struct {
|
||||
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
|
||||
PrivateKey string `form:"privateKey" json:"privateKey"`
|
||||
|
||||
// Duration specifies how long the generated JWT token should be considered valid.
|
||||
// Duration specifies how long the generated JWT should be considered valid.
|
||||
// The specified value must be in seconds and max 15777000 (~6months).
|
||||
Duration int `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
+10
-10
@@ -38,7 +38,7 @@ func TestBackupCreateValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
func() {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -56,47 +56,47 @@ func TestBackupCreateValidateAndSubmit(t *testing.T) {
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.name, result)
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
return
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.name, s.expectedErrors, errs)
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.name, k, errs)
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve all created backup files
|
||||
files, err := fsys.List("")
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to retrieve backup files", s.name)
|
||||
t.Fatal("Failed to retrieve backup files")
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if total := len(files); total != 0 {
|
||||
t.Errorf("[%s] Didn't expected backup files, found %d", s.name, total)
|
||||
t.Fatalf("Didn't expected backup files, found %d", total)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if total := len(files); total != 1 {
|
||||
t.Errorf("[%s] Expected 1 backup file, got %d", s.name, total)
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
return
|
||||
}
|
||||
|
||||
if s.backupName == "" {
|
||||
prefix := "pb_backup_"
|
||||
if !strings.HasPrefix(files[0].Key, prefix) {
|
||||
t.Errorf("[%s] Expected the backup file, to have prefix %q: %q", s.name, prefix, files[0].Key)
|
||||
t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
|
||||
}
|
||||
} else if s.backupName != files[0].Key {
|
||||
t.Errorf("[%s] Expected backup file %q, got %q", s.name, s.backupName, files[0].Key)
|
||||
t.Fatalf("Expected backup file %q, got %q", s.backupName, files[0].Key)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// BackupUpload is a request form for uploading a new app backup.
|
||||
type BackupUpload struct {
|
||||
app core.App
|
||||
ctx context.Context
|
||||
|
||||
File *filesystem.File `json:"file"`
|
||||
}
|
||||
|
||||
// NewBackupUpload creates new BackupUpload request form.
|
||||
func NewBackupUpload(app core.App) *BackupUpload {
|
||||
return &BackupUpload{
|
||||
app: app,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext replaces the default form upload context with the provided one.
|
||||
func (form *BackupUpload) SetContext(ctx context.Context) {
|
||||
form.ctx = ctx
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *BackupUpload) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.File,
|
||||
validation.Required,
|
||||
validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *BackupUpload) checkUniqueName(value any) error {
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
if exists, err := fsys.Exists(v.OriginalName); err != nil || exists {
|
||||
return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and upload the backup file.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before uploading the backup.
|
||||
func (form *BackupUpload) Submit(interceptors ...InterceptorFunc[*filesystem.File]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.File, func(file *filesystem.File) error {
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
return fsys.UploadFile(file, file.OriginalName)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
func TestBackupUploadValidateAndSubmit(t *testing.T) {
|
||||
var zb bytes.Buffer
|
||||
zw := zip.NewWriter(&zb)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f0, _ := filesystem.NewFileFromBytes([]byte("test"), "existing")
|
||||
f1, _ := filesystem.NewFileFromBytes([]byte("456"), "nozip")
|
||||
f2, _ := filesystem.NewFileFromBytes(zb.Bytes(), "existing")
|
||||
f3, _ := filesystem.NewFileFromBytes(zb.Bytes(), "zip")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
file *filesystem.File
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"missing file",
|
||||
nil,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"non-zip file",
|
||||
f1,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"zip file with non-unique name",
|
||||
f2,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"zip file with unique name",
|
||||
f3,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
// create a dummy backup file to simulate existing backups
|
||||
if err := fsys.UploadFile(f0, f0.OriginalName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewBackupUpload(app)
|
||||
form.File = s.file
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
expectedFiles := []*filesystem.File{f0}
|
||||
if result == nil {
|
||||
expectedFiles = append(expectedFiles, s.file)
|
||||
}
|
||||
|
||||
// retrieve all uploaded backup files
|
||||
files, err := fsys.List("")
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve backup files")
|
||||
}
|
||||
|
||||
if len(files) != len(expectedFiles) {
|
||||
t.Fatalf("Expected %d files, got %d", len(expectedFiles), len(files))
|
||||
}
|
||||
|
||||
for _, ef := range expectedFiles {
|
||||
exists := false
|
||||
for _, f := range files {
|
||||
if f.Key == ef.OriginalName {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("Missing expected backup file %v", ef.OriginalName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-22
@@ -229,14 +229,6 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
systemDisplayFields := schema.BaseModelFieldNames()
|
||||
systemDisplayFields = append(systemDisplayFields,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameEmail,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameVerified,
|
||||
)
|
||||
|
||||
for i, field := range v.Fields() {
|
||||
if field.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
@@ -268,10 +260,10 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
if relCollection == nil || relCollection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
@@ -282,17 +274,16 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
// allow only views to have relations to other views
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3000)
|
||||
if form.Type != models.CollectionTypeView && relCollection.IsView() {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_non_view_base_relation_collection",
|
||||
"Non view collections are not allowed to have a view relation.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +370,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule. Raw error: "+err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+170
-146
@@ -171,25 +171,6 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - missing relation display field",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name":"test",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"wsmn24bux7wo113",
|
||||
"displayFields":["text", "missing"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check auth options validators",
|
||||
"",
|
||||
@@ -400,6 +381,54 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
|
||||
// view tests
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
"base->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options":{
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"auth->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options": {
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"view->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select view1.id, view1.id as rel from view1"
|
||||
}
|
||||
}`,
|
||||
[]string{}, // allowed
|
||||
},
|
||||
{
|
||||
"view create failure",
|
||||
"",
|
||||
@@ -495,137 +524,132 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf(
|
||||
"[%s] Missing index %s \nin \n%v", s.testName, normalizedIdx, allIndexes)
|
||||
continue
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Fatalf("Expected to find collection %q, got nil", form.Name)
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Fatalf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package forms
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -78,12 +77,9 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
"collections_import_failure",
|
||||
"Failed to import the collections configuration.",
|
||||
"Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
|
||||
)}
|
||||
})
|
||||
}, interceptors...)
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestCollectionsImportValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmit(t *testing.T) {
|
||||
totalCollections := 10
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -206,7 +206,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 4,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -418,44 +418,45 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Fatalf("Expected events %v, got %v", s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Fatalf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*mo
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
|
||||
// @todo consider removing if not necessary anymore
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
|
||||
return validation.NewError("validation_record_email_exists", "User email already exists.")
|
||||
return validation.NewError("validation_record_email_invalid", "User email already exists or it is invalid.")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -46,7 +46,7 @@ type RecordOAuth2Login struct {
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
// The optional PKCE code verifier as part of the code_challenge sent with the initial request.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
@@ -88,8 +88,7 @@ func (form *RecordOAuth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
validation.Field(&form.RedirectUrl, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,11 +142,14 @@ func (form *RecordOAuth2Login) Submit(
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if provider.PKCE() {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
|
||||
}
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
token, err := provider.FetchToken(form.Code, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -161,7 +163,11 @@ func (form *RecordOAuth2Login) Submit(
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
rel, _ := form.dao.FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": form.collection.Id,
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
|
||||
@@ -23,13 +23,13 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"missing provider",
|
||||
@@ -49,6 +49,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"[#3689] any redirectUrl value",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -69,7 +70,7 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByEmail(form.collection.Id, form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to fetch %s record with email %s: %w", form.collection.Id, form.Email, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -78,14 +79,14 @@ func (form *RecordPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return runInterceptors(authRecord, func(m *models.Record) error {
|
||||
if err := mails.SendRecordPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
m.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func TestRecordPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == authRecord.LastResetSentAt().String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
if interceptorLastResetSentAt.String() != authRecord.LastResetSentAt().String() {
|
||||
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+36
-21
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// username value regex pattern
|
||||
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
|
||||
var usernameRegex = regexp.MustCompile(`^[\w][\w\.\-]*$`)
|
||||
|
||||
// RecordUpsert is a [models.Record] upsert (create/update) form.
|
||||
type RecordUpsert struct {
|
||||
@@ -200,8 +200,12 @@ func (form *RecordUpsert) extractMultipartFormData(
|
||||
|
||||
files, err := rest.FindUploadedFiles(r, fullKey)
|
||||
if err != nil || len(files) == 0 {
|
||||
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
|
||||
log.Printf("%q uploaded file error: %v\n", fullKey, err)
|
||||
if err != nil && err != http.ErrMissingFile {
|
||||
form.app.Logger().Debug(
|
||||
"Uploaded file error",
|
||||
slog.String("key", fullKey),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// skip invalid or missing file(s)
|
||||
@@ -744,36 +748,44 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
|
||||
// upload new files (if any)
|
||||
//
|
||||
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hooks
|
||||
// note: executed after the default BeforeCreateFunc and BeforeUpdateFunc hook actions
|
||||
// to allow uploading AFTER the before app model hooks (eg. in case of an id change)
|
||||
// but BEFORE the actual record db persistence
|
||||
// ---
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if form.dao.BeforeCreateFunc != nil {
|
||||
if err := form.dao.BeforeCreateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
newAction := func() error {
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return action()
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
if form.dao.BeforeCreateFunc != nil {
|
||||
return form.dao.BeforeCreateFunc(eventDao, m, newAction)
|
||||
}
|
||||
|
||||
return nil
|
||||
return newAction()
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
if form.dao.BeforeUpdateFunc != nil {
|
||||
if err := form.dao.BeforeUpdateFunc(eventDao, m); err != nil {
|
||||
return err
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
newAction := func() error {
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return action()
|
||||
}
|
||||
|
||||
if m.TableName() == form.record.TableName() && m.GetId() == form.record.GetId() {
|
||||
return form.processFilesToUpload()
|
||||
if form.dao.BeforeUpdateFunc != nil {
|
||||
return form.dao.BeforeUpdateFunc(eventDao, m, newAction)
|
||||
}
|
||||
|
||||
return nil
|
||||
return newAction()
|
||||
}
|
||||
// ---
|
||||
|
||||
@@ -786,8 +798,11 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc[*models.Record]
|
||||
//
|
||||
// for now fail silently to avoid reupload when `form.Submit()`
|
||||
// is called manually (aka. not from an api request)...
|
||||
if err := form.processFilesToDelete(); err != nil && form.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := form.processFilesToDelete(); err != nil {
|
||||
form.app.Logger().Debug(
|
||||
"Failed to delete old files",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -753,7 +753,7 @@ func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
"create with all allowed auth fields",
|
||||
"",
|
||||
map[string]any{
|
||||
"username": "test_new",
|
||||
"username": "test_new-a.b",
|
||||
"email": "test_new@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "12345678",
|
||||
|
||||
@@ -82,9 +82,6 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
record.SetLastVerificationSentAt(types.NowDateTime())
|
||||
}
|
||||
|
||||
return runInterceptors(record, func(m *models.Record) error {
|
||||
@@ -96,6 +93,9 @@ func (form *RecordVerificationRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
m.SetLastVerificationSentAt(types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestRecordVerificationRequestInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastVerificationSentAt.String() == authRecord.LastVerificationSentAt().String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
if interceptorLastVerificationSentAt.String() != authRecord.LastVerificationSentAt().String() {
|
||||
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-18
@@ -4,9 +4,12 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// SettingsUpsert is a [settings.Settings] upsert (create/update) form.
|
||||
@@ -58,32 +61,27 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc[*settings.Set
|
||||
return runInterceptors(form.Settings, func(s *settings.Settings) error {
|
||||
form.Settings = s
|
||||
|
||||
oldSettings, err := form.app.Settings().Clone();
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// eagerly merge the application settings with the form ones
|
||||
if err := form.app.Settings().Merge(form.Settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// persists settings change
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
if err := form.dao.SaveSettings(form.Settings, encryptionKey); err != nil {
|
||||
// try to revert app settings
|
||||
form.app.Settings().Merge(oldSettings)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
// reload app settings
|
||||
if err := form.app.RefreshSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to clear old logs not matching the new settings
|
||||
createdBefore := time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays).UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date} OR [[level]] < {:level}", dbx.Params{
|
||||
"date": createdBefore,
|
||||
"level": form.Settings.Logs.MinLevel,
|
||||
})
|
||||
form.app.LogsDao().NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
|
||||
|
||||
// no logs are allowed -> try to reclaim preserved disk space after the previous delete operation
|
||||
if form.Settings.Logs.MaxDays == 0 {
|
||||
// no logs are allowed -> reclaim preserved disk space after the previous delete operation
|
||||
form.app.LogsDao().Vacuum()
|
||||
}
|
||||
|
||||
|
||||
@@ -132,11 +132,14 @@ func (validator *RecordDataValidator) checkTextValue(field *schema.SchemaField,
|
||||
|
||||
options, _ := field.Options.(*schema.TextOptions)
|
||||
|
||||
if options.Min != nil && len(val) < *options.Min {
|
||||
// note: casted to []rune to count multi-byte chars as one
|
||||
length := len([]rune(val))
|
||||
|
||||
if options.Min != nil && length < *options.Min {
|
||||
return validation.NewError("validation_min_text_constraint", fmt.Sprintf("Must be at least %d character(s)", *options.Min))
|
||||
}
|
||||
|
||||
if options.Max != nil && len(val) > *options.Max {
|
||||
if options.Max != nil && length > *options.Max {
|
||||
return validation.NewError("validation_max_text_constraint", fmt.Sprintf("Must be less than %d character(s)", *options.Max))
|
||||
}
|
||||
|
||||
@@ -158,6 +161,10 @@ func (validator *RecordDataValidator) checkNumberValue(field *schema.SchemaField
|
||||
|
||||
options, _ := field.Options.(*schema.NumberOptions)
|
||||
|
||||
if options.NoDecimal && val != float64(int64(val)) {
|
||||
return validation.NewError("validation_no_decimal_constraint", "Decimal numbers are not allowed")
|
||||
}
|
||||
|
||||
if options.Min != nil && val < *options.Min {
|
||||
return validation.NewError("validation_min_number_constraint", fmt.Sprintf("Must be larger than %f", *options.Min))
|
||||
}
|
||||
@@ -294,8 +301,14 @@ func (validator *RecordDataValidator) checkJsonValue(field *schema.SchemaField,
|
||||
}
|
||||
|
||||
raw, _ := types.ParseJsonRaw(value)
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
|
||||
options, _ := field.Options.(*schema.JsonOptions)
|
||||
|
||||
if len(raw) > options.MaxSize {
|
||||
return validation.NewError("validation_json_size_limit", fmt.Sprintf("The maximum allowed JSON size is %v bytes", options.MaxSize))
|
||||
}
|
||||
|
||||
rawStr := strings.TrimSpace(raw.String())
|
||||
if field.Required && list.ExistInSlice(rawStr, emptyJsonValues) {
|
||||
return requiredErr
|
||||
}
|
||||
@@ -372,7 +385,7 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
|
||||
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
|
||||
Row(&total)
|
||||
if total != len(ids) {
|
||||
return validation.NewError("validation_missing_rel_records", "Failed to fetch all relation records with the provided ids")
|
||||
return validation.NewError("validation_missing_rel_records", "Failed to find all relation records with the provided ids")
|
||||
}
|
||||
// ---
|
||||
|
||||
|
||||
@@ -63,15 +63,17 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
Name: "field2",
|
||||
Required: true,
|
||||
Type: schema.FieldTypeText,
|
||||
Options: &schema.TextOptions{
|
||||
Pattern: pattern,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Unique: true,
|
||||
Type: schema.FieldTypeText,
|
||||
Options: &schema.TextOptions{
|
||||
Min: &min,
|
||||
Max: &max,
|
||||
Pattern: pattern,
|
||||
Min: &min,
|
||||
Max: &max,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -109,6 +111,16 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check min constraint with multi-bytes char",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
"field3": "𝌆", // 4 bytes should be counted as 1 char
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check max constraint",
|
||||
map[string]any{
|
||||
@@ -119,15 +131,25 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(text) check max constraint with multi-bytes chars",
|
||||
map[string]any{
|
||||
"field1": "test",
|
||||
"field2": "test",
|
||||
"field3": strings.Repeat("𝌆", max), // shouldn't exceed the max limit even though max*4bytes chars are used
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"(text) check pattern constraint",
|
||||
map[string]any{
|
||||
"field1": nil,
|
||||
"field2": "test",
|
||||
"field3": "test!",
|
||||
"field2": "test!",
|
||||
"field3": "test",
|
||||
},
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(text) valid data (only required)",
|
||||
@@ -180,6 +202,13 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
Max: &max,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field4",
|
||||
Type: schema.FieldTypeNumber,
|
||||
Options: &schema.NumberOptions{
|
||||
NoDecimal: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -201,6 +230,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
"field1": nil,
|
||||
"field2": nil,
|
||||
"field3": nil,
|
||||
"field4": nil,
|
||||
},
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
@@ -211,6 +241,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
"field1": "invalid",
|
||||
"field2": "invalid",
|
||||
"field3": "invalid",
|
||||
"field4": "invalid",
|
||||
},
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
@@ -244,6 +275,15 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field3"},
|
||||
},
|
||||
{
|
||||
"(number) check NoDecimal",
|
||||
map[string]any{
|
||||
"field2": 1,
|
||||
"field4": 456.789,
|
||||
},
|
||||
nil,
|
||||
[]string{"field4"},
|
||||
},
|
||||
{
|
||||
"(number) valid data (only required)",
|
||||
map[string]any{
|
||||
@@ -258,6 +298,7 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
|
||||
"field1": nil,
|
||||
"field2": 123, // test value cast
|
||||
"field3": max,
|
||||
"field4": 456,
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
@@ -821,16 +862,25 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
&schema.SchemaField{
|
||||
Name: "field1",
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 10,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field2",
|
||||
Required: true,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 9999,
|
||||
},
|
||||
},
|
||||
&schema.SchemaField{
|
||||
Name: "field3",
|
||||
Unique: true,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 9999,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
@@ -897,6 +947,15 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
nil,
|
||||
[]string{"field2"},
|
||||
},
|
||||
{
|
||||
"(json) check MaxSize constraint",
|
||||
map[string]any{
|
||||
"field1": `"123456789"`, // max 10bytes
|
||||
"field2": 123,
|
||||
},
|
||||
nil,
|
||||
[]string{"field1"},
|
||||
},
|
||||
{
|
||||
"(json) check json text invalid obj, array and number normalizations",
|
||||
map[string]any{
|
||||
@@ -928,9 +987,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
|
||||
{
|
||||
"(json) valid data - all fields with normalizations",
|
||||
map[string]any{
|
||||
"field1": []string{"a", "b", "c"},
|
||||
"field1": `"12345678"`,
|
||||
"field2": 123,
|
||||
"field3": `"test"`,
|
||||
"field3": []string{"a", "b", "c"},
|
||||
},
|
||||
nil,
|
||||
[]string{},
|
||||
@@ -1232,29 +1291,30 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
|
||||
|
||||
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
|
||||
for i, s := range scenarios {
|
||||
validator := validators.NewRecordDataValidator(dao, record, s.files)
|
||||
result := validator.Validate(s.data)
|
||||
|
||||
prefix := fmt.Sprintf("%d", i)
|
||||
if s.name != "" {
|
||||
prefix = s.name
|
||||
prefix := s.name
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("%d", i)
|
||||
}
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", prefix, result)
|
||||
continue
|
||||
}
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
validator := validators.NewRecordDataValidator(dao, record, s.files)
|
||||
result := validator.Validate(s.data)
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", prefix, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", prefix, k, errs)
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,96 @@
|
||||
module github.com/pocketbase/pocketbase
|
||||
|
||||
go 1.18
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/aws/aws-sdk-go v1.44.298
|
||||
github.com/aws/aws-sdk-go v1.48.16
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/domodwyer/mailyak/v3 v3.6.0
|
||||
github.com/dop251/goja v0.0.0-20230427124612-428fc442ff5f
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/ganigeorgiev/fexpr v0.3.0
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2
|
||||
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
|
||||
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/ganigeorgiev/fexpr v0.4.0
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
||||
github.com/goccy/go-json v0.10.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/labstack/echo/v5 v5.0.0-20220201181537-ed2888cfa198
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/pocketbase/dbx v1.10.0
|
||||
github.com/spf13/cast v1.5.1
|
||||
github.com/spf13/cobra v1.7.0
|
||||
gocloud.dev v0.30.0
|
||||
golang.org/x/crypto v0.11.0
|
||||
golang.org/x/net v0.12.0
|
||||
golang.org/x/oauth2 v0.10.0
|
||||
modernc.org/sqlite v1.23.1
|
||||
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pocketbase/tygoja v0.0.0-20231111102932-5420517293f4
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
gocloud.dev v0.35.0
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/net v0.19.0
|
||||
golang.org/x/oauth2 v0.15.0
|
||||
golang.org/x/sync v0.5.0
|
||||
modernc.org/sqlite v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.18.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.18.27 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.71 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.26 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.36.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.12.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.19.2 // indirect
|
||||
github.com/aws/smithy-go v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.26.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
|
||||
github.com/aws/smithy-go v1.19.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230510103437-eeec1cb781c3 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20230926050212-f7f687d19a98 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/google/wire v0.5.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/image v0.9.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/sys v0.10.0 // indirect
|
||||
golang.org/x/term v0.10.0 // indirect
|
||||
golang.org/x/text v0.11.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
golang.org/x/tools v0.11.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/api v0.130.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect
|
||||
google.golang.org/grpc v1.56.2 // indirect
|
||||
golang.org/x/image v0.14.0 // indirect
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
golang.org/x/term v0.15.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.16.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/api v0.153.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
|
||||
google.golang.org/grpc v1.59.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.14 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/libc v1.37.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
run:
|
||||
go: 1.18
|
||||
go: 1.21
|
||||
concurrency: 4
|
||||
timeout: 10m
|
||||
|
||||
|
||||
+5
-10
@@ -2,7 +2,6 @@ package mails
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -67,15 +66,11 @@ func SendAdminPasswordReset(app core.App, admin *models.Admin) error {
|
||||
event.Admin = admin
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterAdminResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeAdminResetPasswordSend().Trigger(event, func(e *core.MailerAdminEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterAdminResetPasswordSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
+15
-28
@@ -2,7 +2,6 @@ package mails
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/mail"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -44,17 +43,13 @@ func SendRecordPasswordReset(app core.App, authRecord *models.Record) error {
|
||||
event.Record = authRecord
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordResetPasswordSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordResetPasswordSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordResetPasswordSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
// SendRecordVerification sends a verification request email to the specified user.
|
||||
@@ -88,17 +83,13 @@ func SendRecordVerification(app core.App, authRecord *models.Record) error {
|
||||
event.Record = authRecord
|
||||
event.Meta = map[string]any{"token": token}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordVerificationSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordVerificationSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordVerificationSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
// SendUserChangeEmail sends a change email confirmation email to the specified user.
|
||||
@@ -135,17 +126,13 @@ func SendRecordChangeEmail(app core.App, record *models.Record, newEmail string)
|
||||
"newEmail": newEmail,
|
||||
}
|
||||
|
||||
sendErr := app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
return e.MailClient.Send(e.Message)
|
||||
})
|
||||
|
||||
if sendErr == nil {
|
||||
if err := app.OnMailerAfterRecordChangeEmailSend().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
return app.OnMailerBeforeRecordChangeEmailSend().Trigger(event, func(e *core.MailerRecordEvent) error {
|
||||
if err := e.MailClient.Send(e.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return sendErr
|
||||
return app.OnMailerAfterRecordChangeEmailSend().Trigger(e)
|
||||
})
|
||||
}
|
||||
|
||||
func resolveEmailTemplate(
|
||||
|
||||
@@ -85,7 +85,7 @@ func init() {
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_collection_provider_idx on {{_externalAuths}} ([[collectionId]], [[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
if tablesErr != nil {
|
||||
return tablesErr
|
||||
@@ -100,7 +100,7 @@ func init() {
|
||||
return err
|
||||
}
|
||||
|
||||
// inserts the system profiles collection
|
||||
// inserts the system users collection
|
||||
// -----------------------------------------------------------
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
|
||||
@@ -12,93 +12,97 @@ import (
|
||||
// Normalizes old single and multiple values of MultiValuer fields (file, select, relation).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.IsView() {
|
||||
// skip view collections
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range c.Schema.Fields() {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if opt.IsMultiple() {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
} else {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trigger view query update after the records normalization
|
||||
// (ignore save error in case of invalid query to allow users to change it from the UI)
|
||||
for _, c := range collections {
|
||||
if !c.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
dao.SaveCollection(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
return normalizeMultivaluerFields(db)
|
||||
}, func(db dbx.Builder) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeMultivaluerFields(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.IsView() {
|
||||
// skip view collections
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range c.Schema.Fields() {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var updateQuery *dbx.Query
|
||||
|
||||
if opt.IsMultiple() {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
} else {
|
||||
updateQuery = dao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
c.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
f.Name,
|
||||
))
|
||||
}
|
||||
|
||||
if _, err := updateQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trigger view query update after the records normalization
|
||||
// (ignore save error in case of invalid query to allow users to change it from the UI)
|
||||
for _, c := range collections {
|
||||
if !c.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
dao.SaveCollection(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// Renormalizes old single and multiple values of MultiValuer fields (file, select, relation)
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/2930).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
return normalizeMultivaluerFields(db)
|
||||
}, func(db dbx.Builder) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Reset all previously inserted NULL values to the fields zero-default.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
if collection.IsView() {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
defaultVal := "''"
|
||||
|
||||
switch f.Type {
|
||||
case schema.FieldTypeJson:
|
||||
continue
|
||||
case schema.FieldTypeBool:
|
||||
defaultVal = "FALSE"
|
||||
case schema.FieldTypeNumber:
|
||||
defaultVal = "0"
|
||||
default:
|
||||
if opt, ok := f.Options.(schema.MultiValuer); ok && opt.IsMultiple() {
|
||||
defaultVal = "'[]'"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.NewQuery(fmt.Sprintf(
|
||||
"UPDATE {{%s}} SET [[%s]] = %s WHERE [[%s]] IS NULL",
|
||||
collection.Name,
|
||||
f.Name,
|
||||
defaultVal,
|
||||
f.Name,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Transform the relation fields to views from non-view collections to json or text fields
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3000).
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
views, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, view := range views {
|
||||
refs, err := dao.FindCollectionReferences(view)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for collection, fields := range refs {
|
||||
if collection.IsView() {
|
||||
continue // view-view relations are allowed
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
opt, ok := f.Options.(schema.MultiValuer)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if opt.IsMultiple() {
|
||||
f.Type = schema.FieldTypeJson
|
||||
f.Options = &schema.JsonOptions{}
|
||||
} else {
|
||||
f.Type = schema.FieldTypeText
|
||||
f.Options = &schema.TextOptions{}
|
||||
}
|
||||
|
||||
// replace the existing field
|
||||
// (this usually is not necessary since it is a pointer,
|
||||
// but it is better to be explicit in case FindCollectionReferences changes)
|
||||
collection.Schema.AddField(f)
|
||||
}
|
||||
|
||||
// "raw" save without records table sync
|
||||
if err := dao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// Resave all view collections to ensure that the proper id normalization is applied.
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110)
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
// ignore errors to allow users to adjust
|
||||
// the view queries after app start
|
||||
dao.SaveCollection(collection)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// Copy the now deprecated RelationOptions.DisplayFields values from
|
||||
// all relation fields and register its value as Presentable under
|
||||
// the specific field in the related collection.
|
||||
//
|
||||
// If there is more than one relation to a single collection with explicitly
|
||||
// set DisplayFields only one of the configuration will be copied.
|
||||
func init() {
|
||||
AppMigrations.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db)
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := dao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexedCollections := make(map[string]*models.Collection, len(collections))
|
||||
for _, collection := range collections {
|
||||
indexedCollections[collection.Id] = collection
|
||||
}
|
||||
|
||||
for _, collection := range indexedCollections {
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
if f.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
}
|
||||
|
||||
options, ok := f.Options.(*schema.RelationOptions)
|
||||
if !ok || len(options.DisplayFields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
relCollection, ok := indexedCollections[options.CollectionId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range options.DisplayFields {
|
||||
relField := relCollection.Schema.GetFieldByName(name)
|
||||
if relField != nil {
|
||||
relField.Presentable = true
|
||||
}
|
||||
}
|
||||
|
||||
// only raw model save
|
||||
if err := dao.Save(relCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user