mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf1745fa13 | ||
|
|
efc095f7d0 | ||
|
|
14e7286840 | ||
|
|
4ace75b3d5 | ||
|
|
d23963aaca | ||
|
|
d35a0d841c | ||
|
|
1b18ab9bec | ||
|
|
93e6ebfe49 | ||
|
|
c3a53cb183 | ||
|
|
ba554b8470 | ||
|
|
61ce760e0f | ||
|
|
7b92b7c857 | ||
|
|
8c127b2849 | ||
|
|
e6b8841421 | ||
|
|
862064e061 | ||
|
|
90594cc331 | ||
|
|
6012ba701d | ||
|
|
5cc95a2e63 | ||
|
|
e41f43241b | ||
|
|
3ad737e606 | ||
|
|
3b49e8489e | ||
|
|
07679dd5ba | ||
|
|
3b8bb4cba9 | ||
|
|
e63fdf4dd0 | ||
|
|
c96415caae | ||
|
|
075e20efae | ||
|
|
624c3357be | ||
|
|
7673798fa3 | ||
|
|
d4987a153e | ||
|
|
b02d9b3662 | ||
|
|
4c44044c0c | ||
|
|
58f605e90c | ||
|
|
6ae3d47eeb | ||
|
|
cb185ad6bf | ||
|
|
f89858f1ec | ||
|
|
0695ca254d | ||
|
|
e91694154f | ||
|
|
b251a4cf65 | ||
|
|
01949b059b | ||
|
|
89f3668da2 | ||
|
|
7865ca7b95 | ||
|
|
d92a98b100 | ||
|
|
e9118fa6b6 | ||
|
|
e49b64b114 | ||
|
|
1204362e9c | ||
|
|
cc535cde3b | ||
|
|
cb44d9e716 | ||
|
|
5cb66bd52f | ||
|
|
3a893d15ad | ||
|
|
64854ef08d | ||
|
|
9f3cdf4ad5 | ||
|
|
2dbc70d60d | ||
|
|
a2b14bcb93 | ||
|
|
864bac6dc4 | ||
|
|
78dc12dc29 | ||
|
|
4b4c2ec7c3 | ||
|
|
d87fa3bd80 | ||
|
|
45d353ffdb | ||
|
|
e5390c3d86 | ||
|
|
e3d2608d03 | ||
|
|
650a4255cc | ||
|
|
de70af2584 | ||
|
|
ba7ed78b73 | ||
|
|
cea149cb6e | ||
|
|
70d8d1ee9d | ||
|
|
29c2e209f4 | ||
|
|
ef465957ff | ||
|
|
ba8b51af58 | ||
|
|
93e3eb3a35 | ||
|
|
cf77c0e7eb | ||
|
|
d4578c3b8c | ||
|
|
c2f3fe6a52 | ||
|
|
bb18799a0b | ||
|
|
4a40c1b897 | ||
|
|
e9d4b1fe77 | ||
|
|
9cefd0128c | ||
|
|
d695e9d180 | ||
|
|
faf96896e7 | ||
|
|
72364bf4e0 | ||
|
|
969d4e2bfb | ||
|
|
430d892e64 | ||
|
|
fb8af7a8cf | ||
|
|
6ecb412849 | ||
|
|
bc7080337e | ||
|
|
c157331721 | ||
|
|
27ab2d45e8 | ||
|
|
d5d8decf6e | ||
|
|
eb28f898d0 | ||
|
|
ab0edb80df | ||
|
|
233dd2ac67 | ||
|
|
21d58d389f | ||
|
|
7f21e31145 | ||
|
|
19ad2f3b04 | ||
|
|
f6675702ea | ||
|
|
3b9f2141fe | ||
|
|
2b39757fbc | ||
|
|
15091999e3 | ||
|
|
fcc680794c | ||
|
|
bc72525013 | ||
|
|
23ca5a77e1 | ||
|
|
266b56ecbb | ||
|
|
ac8a4583cb | ||
|
|
1d72c8487e | ||
|
|
97eb9b300b | ||
|
|
5b2cae8509 | ||
|
|
5715e11e52 | ||
|
|
90e9fa705d | ||
|
|
d4101be9f6 | ||
|
|
6cec679f02 | ||
|
|
0e0b862bd7 | ||
|
|
b2bf26122f | ||
|
|
adc5c3cf18 | ||
|
|
4a1e3aed6e | ||
|
|
55e24344ff | ||
|
|
17086722f4 | ||
|
|
9b036fb10f | ||
|
|
8e3f0f2e32 | ||
|
|
65750bca8d | ||
|
|
adb991eb02 | ||
|
|
d87a887673 | ||
|
|
f2eb295fa2 | ||
|
|
d11a9f9d99 | ||
|
|
bbd7f4e4ae | ||
|
|
b0a5bce4c4 | ||
|
|
9234cbf0d1 | ||
|
|
6bf5eccfa7 | ||
|
|
8bbd0c2d77 | ||
|
|
2af60a85c5 | ||
|
|
56e53e885c | ||
|
|
3115f08d4a | ||
|
|
0b9a646d41 | ||
|
|
5f8cce3558 | ||
|
|
3da2c00f32 | ||
|
|
352ee72740 | ||
|
|
98510103c3 | ||
|
|
25c044a3a2 | ||
|
|
0536c779d5 | ||
|
|
b1da83e516 | ||
|
|
6b5d7b216b | ||
|
|
26d6a98355 | ||
|
|
4abc018d7c | ||
|
|
f0a9cbf31b | ||
|
|
d08da7594a | ||
|
|
c2d6530065 | ||
|
|
94c4d4ec65 | ||
|
|
be7ec34516 | ||
|
|
27cb36ffd7 | ||
|
|
e7af58efac | ||
|
|
ab51b3c038 | ||
|
|
5773f46fea | ||
|
|
4d5b7cc1d0 | ||
|
|
7286f34104 | ||
|
|
4399a6c1ab | ||
|
|
e89603497f | ||
|
|
abb6bcd6de | ||
|
|
5604fe672e | ||
|
|
7825baab13 | ||
|
|
68b9d0e403 | ||
|
|
85232ed6e4 | ||
|
|
d76d4089cf | ||
|
|
2e5f8bff63 | ||
|
|
90d896e1cc | ||
|
|
c13d83adb1 | ||
|
|
9b73295a7c | ||
|
|
3c6ce2de74 | ||
|
|
63b89533a9 | ||
|
|
779059eca3 | ||
|
|
9abdadf0dc | ||
|
|
6500b8c518 | ||
|
|
91f1ca273d | ||
|
|
6e739fd33d | ||
|
|
2525f29c1c | ||
|
|
1dc5e061b8 | ||
|
|
09d7f6a7c3 | ||
|
|
1775585b68 | ||
|
|
f4e6c5edee | ||
|
|
94b11bf2c3 | ||
|
|
ddb8c88a37 | ||
|
|
0f5411d81c | ||
|
|
423d234da1 | ||
|
|
6184b31d82 | ||
|
|
63a9d045a1 | ||
|
|
501ab0e6be | ||
|
|
7ad08ef6bf | ||
|
|
6210f361b0 | ||
|
|
fcb5b5dd67 | ||
|
|
41607679a0 | ||
|
|
ca7e5b7f7b | ||
|
|
593721dcea | ||
|
|
153ad12e64 | ||
|
|
482fe2bce0 | ||
|
|
48489b6a07 | ||
|
|
67ee431585 | ||
|
|
d5dcd01551 | ||
|
|
749bf7815c | ||
|
|
ceae5e005f | ||
|
|
7b6b71e18d | ||
|
|
885d907beb | ||
|
|
afb942bc41 | ||
|
|
83a26d436e | ||
|
|
7b52d0b56a | ||
|
|
5a8eae7089 | ||
|
|
0bd712752f | ||
|
|
0b6157e1cc | ||
|
|
c8980edf85 | ||
|
|
a7ebb98e20 | ||
|
|
69be986132 | ||
|
|
58da159641 | ||
|
|
8acb48b884 | ||
|
|
91b521595a | ||
|
|
52a53b5b91 | ||
|
|
1137a35ded | ||
|
|
280005e35c | ||
|
|
6656d9820a | ||
|
|
0d8b426b0c | ||
|
|
acd12ce9dd | ||
|
|
47d3da28d5 | ||
|
|
6f8524961f | ||
|
|
fda6ad8d5d | ||
|
|
a8321498fd | ||
|
|
ca4902a808 | ||
|
|
348ccfc580 | ||
|
|
77c05dbd2a | ||
|
|
44289a93a2 | ||
|
|
6b6d3b36d3 | ||
|
|
e26905f8e2 | ||
|
|
6ad42bde29 | ||
|
|
54fb4293c5 | ||
|
|
a8dd8be524 | ||
|
|
76a6b9834b | ||
|
|
68ab174f69 | ||
|
|
a095549304 | ||
|
|
6af4d88529 | ||
|
|
5c9570c8de | ||
|
|
546ea248df | ||
|
|
eda90d4555 | ||
|
|
40f2ba731c | ||
|
|
a088cf6379 | ||
|
|
8d3ec418e9 | ||
|
|
f2056f61bd | ||
|
|
6a5e449b3c | ||
|
|
1359a6f8fd | ||
|
|
28de01a188 | ||
|
|
9e13418565 | ||
|
|
172b1f96f7 | ||
|
|
41cc4fd36b | ||
|
|
45af9e201c | ||
|
|
cc902f2df8 | ||
|
|
5e67ec1c1c | ||
|
|
ee8c0a66b6 | ||
|
|
5d964c1b1d | ||
|
|
c0d37fc64a | ||
|
|
58b564557f | ||
|
|
95787da4df | ||
|
|
b99095430a | ||
|
|
ad814c5a37 | ||
|
|
c6621ea1ed | ||
|
|
bda4baac15 | ||
|
|
a2b1b19342 | ||
|
|
819ec1ad5c | ||
|
|
3ca6321907 | ||
|
|
b8f18bd97d | ||
|
|
50dbb7f94f | ||
|
|
09ce863a40 | ||
|
|
13cec96013 | ||
|
|
b1f1d19d7f | ||
|
|
5200f9c493 | ||
|
|
2c8aa2e5fa | ||
|
|
eeae1f64ee | ||
|
|
506172c495 | ||
|
|
d75f5f663c | ||
|
|
92e15c287e | ||
|
|
dd895dee01 | ||
|
|
4b2e75992b | ||
|
|
c498c918ec | ||
|
|
125e99e4c8 | ||
|
|
5461120b04 | ||
|
|
4a7fb95650 | ||
|
|
444fa78252 | ||
|
|
641aa54cfc | ||
|
|
f015911594 | ||
|
|
fadb2e68a2 | ||
|
|
5ca79eb85d | ||
|
|
62c8523070 | ||
|
|
8debafa755 | ||
|
|
0089ceb904 | ||
|
|
6443f2f159 | ||
|
|
0e12169546 | ||
|
|
9d7856a9eb | ||
|
|
306045fa2f | ||
|
|
a9c42d0282 | ||
|
|
f318f461ea | ||
|
|
51bc9f3982 | ||
|
|
2c6f99418f | ||
|
|
3f3b77dcd4 | ||
|
|
db679f9620 | ||
|
|
c76ee987bd | ||
|
|
0d4da9c3be | ||
|
|
fab56f688d | ||
|
|
1610729d92 | ||
|
|
6522bc55b1 | ||
|
|
c8776b7cd9 | ||
|
|
262e78c04e | ||
|
|
0a66e5a286 | ||
|
|
cdfaed4fa3 | ||
|
|
0f73679c3f | ||
|
|
e09f71ae74 | ||
|
|
17082de560 | ||
|
|
88bb8c406e | ||
|
|
2ab50cc77e | ||
|
|
0025ae80ad | ||
|
|
568c63b29f | ||
|
|
6e9a9489a7 | ||
|
|
e73077e7e7 | ||
|
|
0113fecca9 | ||
|
|
fbc378067d | ||
|
|
e80d64414b | ||
|
|
7ffe9f63a5 | ||
|
|
5dbd9821e8 | ||
|
|
87c6c5b483 | ||
|
|
836fc77ddc | ||
|
|
3ef752c232 | ||
|
|
eb8dd80859 | ||
|
|
d97b5b1f6c | ||
|
|
3885c93d59 | ||
|
|
5713cf422b | ||
|
|
c7c590bace | ||
|
|
fac0d5b899 | ||
|
|
902e07e724 | ||
|
|
dc350f0a3e | ||
|
|
1a3efe96ac | ||
|
|
18f152a0e7 | ||
|
|
9bee3bd0fd | ||
|
|
b31a0ddcd3 | ||
|
|
52dcb4192c | ||
|
|
2bffa0e2bb | ||
|
|
8c0ef15ec2 | ||
|
|
08b1b49e17 | ||
|
|
5d46fb054e | ||
|
|
f9b2842deb | ||
|
|
0426722e99 | ||
|
|
33abc0a802 | ||
|
|
51cbe437e5 | ||
|
|
a2b89d7344 | ||
|
|
6988bf0d7c | ||
|
|
02c975467c | ||
|
|
c7ebd68a92 | ||
|
|
0876413d87 | ||
|
|
c0fe600d14 | ||
|
|
aec4870acd | ||
|
|
bf9a7b1e3d | ||
|
|
46186f84f0 | ||
|
|
4cc797071b | ||
|
|
d9af1475ef | ||
|
|
3e3c316da1 | ||
|
|
0efbbb0d10 | ||
|
|
5d32d22ff5 | ||
|
|
e81d85ae7c | ||
|
|
b930de6ff5 | ||
|
|
628efb7a7f | ||
|
|
201e2ccb05 | ||
|
|
e49025c8e5 | ||
|
|
9f6010d38d | ||
|
|
409bcdaa96 | ||
|
|
2554192c06 | ||
|
|
d135b1e686 | ||
|
|
4c5abd5bd9 | ||
|
|
f3a836eb7c | ||
|
|
fb5070d6ab | ||
|
|
c9f39ba167 | ||
|
|
e29655aba9 | ||
|
|
32de8ed04a | ||
|
|
783a99d7cc | ||
|
|
42a008f828 | ||
|
|
d0d5f3d005 | ||
|
|
b5be7f2d3c | ||
|
|
334a81c837 | ||
|
|
d68786df9c | ||
|
|
70f0e7e141 | ||
|
|
75bba1d0f9 | ||
|
|
81cc276ab9 | ||
|
|
ffc848e92d | ||
|
|
778869c318 | ||
|
|
8e7e0aa5ff | ||
|
|
e718b88391 | ||
|
|
865408a9b4 | ||
|
|
7407bdcaf5 | ||
|
|
953feb47fd | ||
|
|
16539335be | ||
|
|
60a4e3f4d2 | ||
|
|
d61ec398a2 | ||
|
|
9f1946057f | ||
|
|
824c7db388 | ||
|
|
e6573d2549 | ||
|
|
8aa3545efe | ||
|
|
7f7d451b31 | ||
|
|
44007a7c8c | ||
|
|
613af90fac | ||
|
|
64e2931379 | ||
|
|
6e9ecb2b7a | ||
|
|
04a3dc7bb7 | ||
|
|
24c4b63960 | ||
|
|
4ced91f95d | ||
|
|
33613b27b0 | ||
|
|
4e148f7224 | ||
|
|
4a7a639df1 | ||
|
|
ed36add853 | ||
|
|
f799083c4f | ||
|
|
087eaa7ea4 | ||
|
|
803ebb8f40 | ||
|
|
58dab5bf70 | ||
|
|
856cc604a7 | ||
|
|
3912874517 | ||
|
|
5c58703a59 | ||
|
|
c054c3b0a5 | ||
|
|
3da16b2d74 | ||
|
|
dd5afd44d2 | ||
|
|
501c49012e | ||
|
|
48a9b82024 | ||
|
|
4155f50fe1 | ||
|
|
653f2d8b16 | ||
|
|
eb3129d1f3 | ||
|
|
4db497c5e1 | ||
|
|
973916bb48 | ||
|
|
d607695600 | ||
|
|
3f51fb941b | ||
|
|
4d40463d8d | ||
|
|
543b8f5e7a | ||
|
|
8e39998f63 | ||
|
|
e8a554b4ad | ||
|
|
aaa3d67659 | ||
|
|
5c41938cb9 | ||
|
|
f1d44c7847 | ||
|
|
26f0df36bc | ||
|
|
6a7f3a21fb | ||
|
|
9da7a8f72b | ||
|
|
9856c59de0 |
+86
-2
@@ -1,5 +1,89 @@
|
||||
# Security
|
||||
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io**.
|
||||
**Keep in mind that PocketBase is a non-commercial open source project, maintained entirely on volunteer basis (there is no company or dedicated team behind it), and there are no bounties!**
|
||||
|
||||
All reports will be promptly addressed and you'll be credited in the fix release notes.
|
||||
If you discover a security vulnerability within PocketBase, please send an e-mail to **support at pocketbase.io** or submit a private [GitHub Security advisory](https://github.com/pocketbase/pocketbase/security/advisories).
|
||||
|
||||
I try to be as responsive as possible and usually address security reports within a day or two, but if you didn't receive a reply from me for more than 5 days it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
|
||||
In case the vulnerability is confirmed, within another couple days I'll try to submit a fix, GitHub security advisory and CVE with remediation steps and **minimal details** regarding the found exploit to minimize giving too much hints to malicious actors (you'll be credited both in the fix release notes and in the public report).
|
||||
|
||||
### Please:
|
||||
|
||||
- DO NOT use LLM as part of your report or email communication - it is extremely frustrating to spend an hour or more reading a wall of generated text, writing an elaborate reply and then to receive another generic LLM prompt response in return.
|
||||
|
||||
- DO NOT reserve and publish MITRE CVE number on your own _(I prefer to do it through the GitHub Security advisory)_ and try to communicate first privately the details to better understand how the code is being used and whether the supposed vulnerability can be actually exploited in any real practical scenarios. Otherwise you are risking needlessly causing scaremongering and annoyance for users that rely on security scanners as part of their CI/CD pipeline.
|
||||
|
||||
- Wait before publicly disclosing and sharing details about the found vulnerability, **ideally at least 5 days after the fix**, to make it harder to exploit and give enough time for users to patch their instances _(you are free to provide a PoC and as much details as you want in your own blog/gist/etc.)_.
|
||||
|
||||
### Below is a short list of previous reports that are NOT considered security issues:
|
||||
|
||||
<details>
|
||||
<summary><strong>Stored XSS</strong></summary>
|
||||
|
||||
This was discussed several times, both privately and [publicly](https://github.com/pocketbase/pocketbase/discussions/6694), but I remain on the opinion that it should be handled primarily on the client-side.
|
||||
|
||||
Modern browsers recently introduced a basic [`Sanitizer` interface](https://developer.mozilla.org/en-US/docs/Web/API/Sanitizer) that could help filtering HTML strings without external libraries.
|
||||
|
||||
Having also a default [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) either as meta tag or response header is always a good idea to minimize the risk of XSS.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>SQL injection in low level DB methods like <code>app.DeleteTable(dangerousName)</code></strong></summary>
|
||||
|
||||
This is working correctly and it is not an issue but it is a common report most likely found by LLM or some other automated tools that may have stumbled on the [NB! code comments](https://pkg.go.dev/github.com/pocketbase/pocketbase@master/core#BaseApp.DeleteTable).
|
||||
|
||||
Raw SQL statements, table and column names are not parameterized and they are vulnerable to SQL injection if used with untrusted input. The documentation as seen above already warns against it. In recent PocketBase releases, many of the arguments of these methods were also prefixed with `dangerous*` to make it even more clear that they should be used with caution.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Race conditions</strong></summary>
|
||||
|
||||
To avoid DB locks PocketBase deliberately tries to minimize the use of DB transactions.
|
||||
This means that operations like record update don't wrap out of the box for example the `SELECT` and `UPDATE` SQL statements in a single transaction, and this can technically lead to a race condition if multiple users edit the same record.
|
||||
|
||||
This is an accepted tradeoff and for the majority of cases it has no security implications.
|
||||
|
||||
This also apply for the read and delete of MFA and OTP records but for those cases, since they operate in a security sensitive context, they have an extra short-lived duration that is configurable from the collection settings _(there are also system cron jobs that takes care for deleting forgotten/expired entries to prevent accumulation of invalid records)_.
|
||||
|
||||
For the cases where transactions are really needed, users can utilize the [Batch Web API](https://pocketbase.io/docs/api-records/#batch-createupdateupsertdelete-records) or [create a transaction programmatically](https://pocketbase.io/docs/go-records/#transaction) _(with PocketBase v0.23+ it is also possible to wrap an entire hook chain in a single transaction)_.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>List/Search side-channel attacks</strong></summary>
|
||||
|
||||
Over the years we've implemented several extra checks to minimize the risk of List/Search side-channel attacks (see especially [v0.32.0](https://github.com/pocketbase/pocketbase/blob/master/CHANGELOG.md#v0320)) but users need to be aware that all client-side filtered fields are technically subject to timing attacks _(whether they are practical or not is a different topic)_.
|
||||
|
||||
This is by design and it is accepted tradeoff between performance, security and usability.
|
||||
|
||||
If you are concerned about timing attacks and have security sensitive collection data such as `secret`, `code`, `token`, etc. then the general recommendation is to mark their related fields as "Hidden" in order to disallow use in client-side filters.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Connecting to a vulnerable OAuth2 provider</strong></summary>
|
||||
|
||||
Because PocketBase v0.23+ supports automatically uploading the OAuth2 avatar on user create _(need to be specified from the auth collection OAuth2 fields mapping)_ some security researchers raised a concern regarding a Blind SSRF but this implies that an attacker controls the OAuth2 vendor and this is a very serious assumption in the first place.
|
||||
|
||||
The entire OAuth2 flow relies that the application server (PocketBase) trusts the configured OAuth2 vendor.
|
||||
If you suspect that an OAuth2 vendor is malicious and cannot be trusted then you MUST NOT use that OAuth2 vendor at all and you should report it.
|
||||
|
||||
If someone is able to tamper with the OAuth2 responses then the entire OAuth2 flow can be thrown out of the window because they will be practically able to authenticate as any of your existing users and the eventual avatar URL probing request is the least of your problem.
|
||||
|
||||
~Nonetheless, in future PocketBase releases there will be [extra `localhost` domain like checks](https://github.com/orgs/pocketbase/projects/2/views/1?pane=issue&itemId=159545722) when assigning the OAuth2 avatar URL to a `file` field that will further minimize the risk of internal network probing requests in case of a vulnerable OAuth2 provider.~ _Done._
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
|
||||
|
||||
Just for the past month, due to some corporate security scanners 5 different people raised concerns over [CVE-2023-36308](https://nvd.nist.gov/vuln/detail/CVE-2023-36308) but this is not really a vulnerability, especially not in PocketBase.
|
||||
|
||||
[`disintegration/imaging`](https://github.com/disintegration/imaging) is a direct PocketBase dependency responsible for the thumbs generation.
|
||||
|
||||
First, a panic (similar to exception in other languages) is NOT a security issue and Go programs usually have to be written defensively with that in mind. In PocketBase specifically all routes have auto panic-recover handling, no matter what the source of the panic is, so the worst case scenario would be an HTTP error response when attempting to access the thumb.
|
||||
|
||||
Second, the related issue that the CVE describes is probably caused by a bug in an outdated `golang.org/x/image` dependency listed in the `go.mod` of that package but PocketBase uses a newer patched version of it that is expected to take precedence.
|
||||
|
||||
Third, even if that issue is still available, with PocketBase it would have been triggerable ONLY if we supported TIFF thumbs generation but we don't. The supported thumbs formats at the moment are JPG, PNG, GIF (its first frame) and partially WebP (stored as PNG). All other images are served as it is, without any transformation.
|
||||
|
||||
In the future I may consider eventually replacing the library because it is no longer actively maintained but as of now it is working correctly and as expected for our use case and you can safely flag the security warning as false-positive.
|
||||
</details>
|
||||
|
||||
@@ -16,19 +16,19 @@ jobs:
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.17.0
|
||||
node-version: '>=25.2.1'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.23.6'
|
||||
go-version: '>=1.26.2'
|
||||
|
||||
# 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.
|
||||
|
||||
+614
-4
@@ -1,3 +1,613 @@
|
||||
## v0.37.2
|
||||
|
||||
- Fixed autoexpandable input in Firefox ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
|
||||
|
||||
- Slightly adjusted the dark theme colors for better readability ([#7648](https://github.com/pocketbase/pocketbase/discussions/7648)).
|
||||
|
||||
- Removed unnecessary tags stripping from the displayed log attributes ([#7649](https://github.com/pocketbase/pocketbase/issues/7649)).
|
||||
|
||||
- Workarounded Safari freeze caused by a buggy CSS popover property ([#7650](https://github.com/pocketbase/pocketbase/issues/7650)).
|
||||
|
||||
|
||||
## v0.37.1
|
||||
|
||||
- Minor UI bugfixes:
|
||||
- Fixed `number` field input values normalization ([#7646](https://github.com/pocketbase/pocketbase/issues/7646)).
|
||||
- Allow opening collections in new tab with middle click.
|
||||
- Show collection name in the page title on initial load.
|
||||
|
||||
|
||||
## v0.37.0
|
||||
|
||||
- New UI rewritten from scratch and with support for external customization in mind.
|
||||
> Note that as explained in [#7612](https://github.com/pocketbase/pocketbase/discussions/7612) the new UI kit and extensions APIs will intentionally remain undocumented until "Stage 2 completion" _(there no ETAs)_.
|
||||
|
||||
The new UI also introduced several other small improvements:
|
||||
- ~2MB smaller bundle size.
|
||||
- Dark mode and theming support.
|
||||
- Basic responsive/mobile support _(it is far from perfect but certainly more usable than before)_.
|
||||
- Help text option for the collection fields.
|
||||
- Lifted the max nested level restriction of presentable relations _(children are lazy loaded)_.
|
||||
- Lighter rules autocomplete.
|
||||
- Live view query preview.
|
||||
- Insert of an audio/video embed tag in the richtext editor from a collection file.
|
||||
- Option to bulk export records as JSON.
|
||||
- Local search history for all searchbars.
|
||||
- API rules overview across all collections.
|
||||
- Very basic ERD-like visualization for the collections structure and relations.
|
||||
- New stepped logs chart visualization with panning support.
|
||||
- `listAuthMethods()` (aka. `/api/collection/{col}/auth-methods`) now returns the OAuth2 provider logo for each provider as inlined SVG string in its response data.
|
||||
_⚠️ Note that if your app for whatever reason rely on the dashboard OAuth2 logos available under `/_/images/oauth2/*` they are still available for now but will be removed in future versions and it is recommended to use the new inline SVGs!_
|
||||
|
||||
- Added optional `no_ui` build tag to exclude the UI from bundling with the executable ([#7548](https://github.com/pocketbase/pocketbase/issues/7548)).
|
||||
```sh
|
||||
go build -tags no_ui
|
||||
```
|
||||
|
||||
- Exported the internal JSVM bind functions ([#7600](https://github.com/pocketbase/pocketbase/discussions/7600)).
|
||||
```go
|
||||
jsvm.BindCore(vm)
|
||||
jsvm.BindDbx(vm)
|
||||
jsvm.BindSecurity(vm)
|
||||
jsvm.BindOS(vm)
|
||||
jsvm.BindFilepath(vm)
|
||||
jsvm.BindHTTP(vm)
|
||||
jsvm.BindFilesystem(vm)
|
||||
jsvm.BindForms(vm)
|
||||
jsvm.BindMails(vm)
|
||||
jsvm.BindApis(vm)
|
||||
```
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.49.1 (SQLite 3.53.0).
|
||||
|
||||
|
||||
## v0.36.9
|
||||
|
||||
- Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135).
|
||||
|
||||
- Fixed settings SMTP password clear persistence.
|
||||
|
||||
- Added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor.
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
|
||||
|
||||
- Updated min Go GitHub action version to 1.26.2 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
|
||||
|
||||
- Other small improvements _(updated `$apis.static` JSVM documentation, fixed comment typos, added missing file close on seek error, etc.)_.
|
||||
|
||||
|
||||
## v0.36.8
|
||||
|
||||
- Fixed OAuth2 client secret reset when serializing a cached collection model.
|
||||
|
||||
- Bumped all Go and npm deps.
|
||||
_This should also silence recent spam reports and security scanners regarding `golang.org/x/image` [CVE-2026-33809](https://www.cve.org/CVERecord?id=CVE-2026-33809) (it is not an issue in PocketBase because we don't support TIFF thumbs)._
|
||||
|
||||
|
||||
## v0.36.7
|
||||
|
||||
- Fixed high memory usage with large file uploads ([#7572](https://github.com/pocketbase/pocketbase/discussions/7572)).
|
||||
|
||||
- Updated the rate limiter reset rules to follow a more traditional fixed window strategy _(aka. to be more close to how it is presented in the UI - allow max X user requests under Ys)_ since several users complained that the older algorithm was not intuitive and not suitable for large intervals.
|
||||
_Approximated sliding window strategy was also suggested as a better compromise option to help minimize traffic spikes right after reset but the additional tracking could introduce some overhead and for now it is left aside until we have more tests._
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||
|
||||
- Updated other minor Go and npm deps.
|
||||
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||
|
||||
|
||||
## v0.36.6
|
||||
|
||||
- Set `NumberField.OnlyInt:true` for the generated View collection schema fields when a view column expression is known to return int-only values ([#7538](https://github.com/pocketbase/pocketbase/issues/7538)).
|
||||
|
||||
- Documented the `unmarshal` JSVM helper ([#7543](https://github.com/pocketbase/pocketbase/issues/7543)).
|
||||
|
||||
- Added extra read check after the `Store.GetOrSet` write lock to prevent races overwriting an already existing value.
|
||||
|
||||
- Added empty records check for the additional client-side filter's ListRule constraint that was introduced in v0.32.0 ([presentator#206](https://github.com/presentator/presentator/issues/206)).
|
||||
|
||||
- Set a fixed `routine.FireAndForget()` debug stack trace limit to 2KB.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||
|
||||
- Typos and other minor doc fixes.
|
||||
|
||||
|
||||
## v0.36.5
|
||||
|
||||
- Disabled collection and fields name normalization while in IME mode ([#7532](https://github.com/pocketbase/pocketbase/pull/7532); thanks @miaopan607).
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.46.1 _(resets connection state on Tx.Commit failure)_.
|
||||
|
||||
|
||||
## v0.36.4
|
||||
|
||||
- Made the optional `Bearer` token prefix case-insensitive ([#7525](https://github.com/pocketbase/pocketbase/pull/7525); thanks @benjamesfleming).
|
||||
|
||||
- Enabled `$filesystem.s3(...)` and `$filesystem.local(...)` JSVM bindings ([#7526](https://github.com/pocketbase/pocketbase/issues/7526)).
|
||||
|
||||
|
||||
## v0.36.3
|
||||
|
||||
- Added `Accept-Encoding: identity` to the S3 requests per the suggestion in [#7523](https://github.com/pocketbase/pocketbase/issues/7523).
|
||||
_This should help fixing the 0-bytes file response when S3 API compression is enabled._
|
||||
|
||||
- Bumped min Go GitHub action version to 1.26.0 _(it comes with minor [GC performance improvements](https://go.dev/doc/go1.26#runtime))_.
|
||||
|
||||
- Other minor fixes _(updated `modernc.org/sqlite` to v1.45.0, updated `goja_nodejs` adding `Buffer.concat`, updated the arguments of `app.DeleteTable(...)`, `app.DeleteView(...)` and other similar methods to make it more clear that they are dangerous and shouldn't be used with untrusted input, etc.)_.
|
||||
|
||||
|
||||
## v0.36.2
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.3 _(race check fix)_, `goja` _(circular references fix)_ and other go deps.
|
||||
|
||||
- Other minor fixes _(updated tests to silence some of the race detector errors, updated `FindFirstRecordByData` with more clear error message when missing or invalid key is used, etc.)_.
|
||||
|
||||
|
||||
## v0.36.1
|
||||
|
||||
- Reverted the `DISTINCT` with `GROUP BY` replacement optimization from v0.36.0 as it was reported to negatively impact the indexes utilization for some queries
|
||||
and the minor performance boost that you may get when used on large records is not enough to justify the more common use ([#7461](https://github.com/pocketbase/pocketbase/discussions/7461)).
|
||||
_A better generic deduplication optimization for large records (aka. records with large `text`/`json` fields or many small ones) will be researched but there are no ETAs._
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.2 _(SQLite 3.51.2)_.
|
||||
|
||||
- Fixed code comment typos.
|
||||
|
||||
|
||||
## v0.36.0
|
||||
|
||||
- List query and API rules optimizations:
|
||||
- Removed unnecessary correlated subquery expression when using back-relations via single `relation` field.
|
||||
- Replaced `DISTINCT` with `GROUP BY id` when rows deduplication is needed and when deemed safe.
|
||||
_This should help with having a more stable and predictable performance even if the collection records are on the larger side._
|
||||
|
||||
For some queries and data sets the above 2 optimizations have shown significant improvements but if you notice a performance degradation after upgrading,
|
||||
please open a Q&A discussion with export of your collections structure and the problematic request so that it can be analyzed.
|
||||
|
||||
- Added [`strftime(format, timevalue, modifiers...)`](https://pocketbase.io/docs/api-rules-and-filters/#strftimeformat-time-value-modifiers-) date formatting filter and API rules function.
|
||||
It works similarly to the [SQLite `strftime` builtin function](https://sqlite.org/lang_datefunc.html)
|
||||
with the main difference that NULL results will be normalized for consistency with the non-nullable PocketBase `text` and `date` fields.
|
||||
Multi-match expressions are also supported and works the same as if the collection field is referenced, for example:
|
||||
```js
|
||||
// requires ANY/AT-LEAST-ONE-OF multiRel records to have "created" date matching the formatted string "2026-01"
|
||||
strftime('%Y-%m', multiRel.created) ?= '2026-01'
|
||||
|
||||
// requires ALL multiRel records to have "created" date matching the formatted string "2026-01"
|
||||
strftime('%Y-%m', multiRel.created) = '2026-01'
|
||||
```
|
||||
|
||||
- ⚠️ Minor changes to the `search.ResolverResult` struct _(mostly used internally)_:
|
||||
- Replaced `NoCoalesce` field with the more explicit `NullFallback` _(`NullFallbackDisabled` is the same as `NoCoalesce:true`)_.
|
||||
- Replaced the expression interface of the `MultiMatchSubQuery` field with the concrete struct type `search.MultiMatchSubquery` to avoid excessive type assertions and allow direct mutations of the field.
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.44.1 _(SQLite 3.51.1)_.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||
|
||||
|
||||
## v0.35.1
|
||||
|
||||
- Updated `modernc.org/sqlite` to v1.43.0 _(query cancellation race fix)_.
|
||||
|
||||
- Other minor UI fixes (normalized relations picker selection and confirmation message when `maxSelect=0/1`, updated node deps).
|
||||
|
||||
|
||||
## v0.35.0
|
||||
|
||||
- Added `nullString()`, `nullInt()`, `nullFloat()`, `nullBool`, `nullArray()`, `nullObject()` JSVM helpers for scanning nullable columns ([#7396](https://github.com/pocketbase/pocketbase/issues/7396)).
|
||||
|
||||
- Store the correct `image/png` as attrs content type when generating a thumb fallback _(e.g. for `webp`)_.
|
||||
|
||||
- Trimmed custom uploaded file name and extension from leftover `.` characters after `filesystem.File` normalization.
|
||||
_This was done to prevent issues with external files sync programs that may have special handling for "invisible" files._
|
||||
|
||||
- Updated `modernc.org/sqlite` _(v1.41.0 includes prepared statements optimization)_ and other minor Go deps.
|
||||
|
||||
|
||||
## v0.34.2
|
||||
|
||||
- Bumped JS SDK to v0.26.5 to fix Safari AbortError detection introduced with the previous release ([#7369](https://github.com/pocketbase/pocketbase/issues/7369)).
|
||||
|
||||
|
||||
## v0.34.1
|
||||
|
||||
- Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353); thanks @ouvreboite).
|
||||
|
||||
- Added "Copy raw JSON" collection dropdown option ([#7357](https://github.com/pocketbase/pocketbase/issues/7357)).
|
||||
|
||||
- Updated Go deps and JS SDK.
|
||||
|
||||
- Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
|
||||
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
|
||||
|
||||
|
||||
## v0.34.0
|
||||
|
||||
- Added `@request.body.someField:changed` modifier.
|
||||
It could be used when you want to ensure that a body field either wasn't submitted or was submitted with the same value.
|
||||
Or in other words, if you want to disallow a field change the below 2 expressions would be equivalent:
|
||||
```js
|
||||
// (old)
|
||||
(@request.body.someField:isset = false || @request.body.someField = someField)
|
||||
|
||||
// (new)
|
||||
@request.body.someField:changed = false
|
||||
```
|
||||
|
||||
- Added `MailerRecordEvent.Meta["info"]` property for the `OnMailerRecordAuthAlertSend` hook.
|
||||
|
||||
- Updated the backup restore popup with a short info about the performed restore steps.
|
||||
|
||||
- Updated Go deps.
|
||||
|
||||
|
||||
## v0.33.0
|
||||
|
||||
- Added extra `id` characters validation in addition to the user specified regex pattern ([#7312](https://github.com/pocketbase/pocketbase/issues/7312)).
|
||||
_The following special characters are always forbidden: `./\|"'``<>:?*%$\n\r\t\0 `. Common reserved Windows file names such as `aux`, `prn`, `con`, `nul`, `com1-9`, `lpt1-9` are also not allowed._
|
||||
_The list is not exhaustive but it should help minimizing eventual filesystem compatibility issues in case of wildcards or other loose regex patterns._
|
||||
|
||||
- Added `{ALERT_INFO}` placeholder to the auth alert mail template ([#7314](https://github.com/pocketbase/pocketbase/issues/7314)).
|
||||
_⚠️ `mails.SendRecordAuthAlert(app, authRecord, info)` also now accepts a 3rd `info` string argument._
|
||||
|
||||
- Updated Go deps.
|
||||
|
||||
|
||||
## v0.32.0
|
||||
|
||||
- ⚠️ Added extra List/Search API rules checks for the client-side `filter`/`sort` relations.
|
||||
|
||||
This is continuation of the effort to eliminate the risk of information disclosure _(and eventually the side-channel attacks that may originate from that)_.
|
||||
|
||||
So far this was accepted tradeoff between performance, usability and correctness since the solutions at the time weren't really practical _(especially with the back-relations as mentioned in ["Security and performance" section in #4417](https://github.com/pocketbase/pocketbase/discussions/4417))_, but with v0.23+ changes we can implement the extra checks without littering the code too much, with very little impact on the performance and at the same time ensuring better out of the box security _(especially for the cases where users operate with sensitive fields like "code", "token", "secret", etc.)_.
|
||||
|
||||
Similar to the previous release, probably for most users with already configured API rules this change won't be breaking, but if you have an _intermediate/junction collection_ that is "locked" (superusers-only) we no longer will allow the client-side relation filter to pass through it and you'll have to set its List/Search API rule to enable the current user to search in it.
|
||||
|
||||
For example, if you have a client-side filter that targets `rel1.rel2.token`, the client must have not only List/Search API rule access to the main collection BUT also to the collections referenced by "rel1" and "rel2" relation fields.
|
||||
|
||||
Note that this change is only for the **client-side** `filter`/`sort` and doesn't affect the execution of superuser requests, API rules and `expand` - they continue to work the same as it is.
|
||||
|
||||
An optional environment variable to toggle this behavior was considered but for now I think having 2 ways of resolving client-side filters would introduce maintenance burden and can even cause confusion (this change should actually make things more intuitive and clear because we can simply say something like _"you can search by a collection X field only if you have List/Search API rule access to it"_ no matter whether the targeted collection is the request's main collection, the first or last relation from the filter chain, etc.).
|
||||
|
||||
If you stumble on an error or extreme query performance degradation as a result of the extra checks, please open a Q&A discussion with the failing request and export of your collections configuration as JSON (_Settings > Export collections_) and I'll try to investigate it.
|
||||
|
||||
- Increased the default SQLite `PRAGMA cache_size` to ~32MB.
|
||||
|
||||
- Fixed deadlock when manually triggering the `OnTerminate` hook ([#7305](https://github.com/pocketbase/pocketbase/pull/7305); thanks @yerTools).
|
||||
|
||||
- Fixed some code comment typos, regenerated the JSVM types and updated npm dependencies.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.40.0.
|
||||
|
||||
|
||||
## v0.31.0
|
||||
|
||||
- Visualize presentable multiple `relation` fields ([#7260](https://github.com/pocketbase/pocketbase/issues/7260)).
|
||||
|
||||
- Support Ed25519 in the optional OIDC `id_token` signature validation ([#7252](https://github.com/pocketbase/pocketbase/issues/7252); thanks @shynome).
|
||||
|
||||
- Added `ApiScenario.DisableTestAppCleanup` optional field to skip the auto test app cleanup and leave it up to the developers to do the cleanup manually ([#7267](https://github.com/pocketbase/pocketbase/discussions/7267)).
|
||||
|
||||
- Added `FileDownloadRequestEvent.ThumbError` field that is populated in case of a thumb generation failure (e.g. unsupported format, timing out, etc.), allowing developers to reject the thumb fallback and/or supply their own custom thumb generation ([#7268](https://github.com/pocketbase/pocketbase/discussions/7268)).
|
||||
|
||||
- ⚠️ Disallow client-side filtering and sorting of relations where the collection of the last targeted relation field has superusers-only List/Search API rule to further minimize the risk of eventual side-channel attack.
|
||||
_This should be a non-breaking change for most users, but if you want the old behavior, please open a new Q&A discussion with details about your use case to evaluate making it configurable._
|
||||
_Note also that as mentioned in the "Security and performance" section of [#4417](https://github.com/pocketbase/pocketbase/discussions/4417) and [#5863](https://github.com/pocketbase/pocketbase/discussions/5863), the easiest and recommended solution to protect security sensitive fields (tokens, codes, passwords, etc.) is to mark them as "Hidden" (aka. make them non-API filterable)._
|
||||
|
||||
- Regenerated JSVM types and updated npm and Go deps.
|
||||
|
||||
|
||||
## v0.30.4
|
||||
|
||||
- Fixed `json` field CSS regression introduced with the overflow workaround in v0.30.3 ([#7259](https://github.com/pocketbase/pocketbase/issues/7259)).
|
||||
|
||||
|
||||
## v0.30.3
|
||||
|
||||
- Fixed legacy identitity field priority check when a username is a valid email address ([#7256](https://github.com/pocketbase/pocketbase/issues/7256)).
|
||||
|
||||
- Workaround autocomplete overflow issue with Firefox 144 ([#7223](https://github.com/pocketbase/pocketbase/issues/7223)).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.39.1 (SQLite 3.50.4).
|
||||
|
||||
|
||||
## v0.30.2
|
||||
|
||||
- Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.30.1
|
||||
|
||||
- ⚠️ Excluded the `lost+found` directory from the backups ([#7208](https://github.com/pocketbase/pocketbase/pull/7208); thanks @lbndev).
|
||||
_If for some reason you want to keep it, you can restore it by editing the `e.Exclude` list of the `OnBackupCreate` and `OnBackupRestore` hooks._
|
||||
|
||||
- Minor tests improvements (disabled initial superuser creation for the test app to avoid cluttering the std output, added more tests for the `s3.Uploader.MaxConcurrency`, etc.).
|
||||
|
||||
- Updated `modernc.org/sqlite` and other Go dependencies.
|
||||
|
||||
|
||||
## v0.30.0
|
||||
|
||||
- Eagerly escape the S3 request path following the same rules as in the S3 signing header ([#7153](https://github.com/pocketbase/pocketbase/issues/7153)).
|
||||
|
||||
- Added Lark OAuth2 provider ([#7130](https://github.com/pocketbase/pocketbase/pull/7130); thanks @mashizora).
|
||||
|
||||
- Increased test tokens `exp` claim to minimize eventual issues with reproducible builds ([#7123](https://github.com/pocketbase/pocketbase/issues/7123)).
|
||||
|
||||
- Added `os.Root` bindings to the JSVM ([`$os.openRoot`](https://pocketbase.io/jsvm/functions/_os.openRoot.html), [`$os.openInRoot`](https://pocketbase.io/jsvm/functions/_os.openInRoot.html)).
|
||||
|
||||
- Added `osutils.IsProbablyGoRun()` helper to loosely check if the program was started using `go run`.
|
||||
|
||||
- Various minor UI improvements (updated collections indexes UI, enabled seconds in the datepicker, updated helper texts, etc.).
|
||||
|
||||
- ⚠️ Updated the minimum package Go version to 1.24.0 and bumped Go dependencies.
|
||||
|
||||
|
||||
## v0.29.3
|
||||
|
||||
- Try to forward Apple OAuth2 POST redirect user's name so that it can be returned (and eventually assigned) with the success response of the all-in-one auth call ([#7090](https://github.com/pocketbase/pocketbase/issues/7090)).
|
||||
|
||||
- Fixed `RateLimitRule.Audience` code comment ([#7098](https://github.com/pocketbase/pocketbase/pull/7098); thanks @iustin05).
|
||||
|
||||
- Mocked `syscall.Exec` when building for WASM ([#7116](https://github.com/pocketbase/pocketbase/pull/7116); thanks @joas8211).
|
||||
_Note that WASM is not officially supported PocketBase build target and many things may not work as expected._
|
||||
|
||||
- Registered missing `$filesystem`, `$mails`, `$template` and `__hooks` bindings in the JSVM migrations ([#7125](https://github.com/pocketbase/pocketbase/issues/7125)).
|
||||
|
||||
- Regenerated JSVM types to include methods from structs with single generic parameter.
|
||||
|
||||
- Updated Go dependencies.
|
||||
|
||||
|
||||
## v0.29.2
|
||||
|
||||
- Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.29.1
|
||||
|
||||
- Updated the X/Twitter provider to return the `confirmed_email` field and to use the `x.com` domain ([#7035](https://github.com/pocketbase/pocketbase/issues/7035)).
|
||||
|
||||
- Added Box.com OAuth2 provider ([#7056](https://github.com/pocketbase/pocketbase/pull/7056); thanks @blakepatteson).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.38.2 (SQLite 3.50.3).
|
||||
|
||||
- Fixed example List API response ([#7049](https://github.com/pocketbase/pocketbase/pull/7049); thanks @williamtguerra).
|
||||
|
||||
|
||||
## v0.29.0
|
||||
|
||||
- Enabled calling the `/auth-refresh` endpoint with nonrenewable tokens.
|
||||
_When used with nonrenewable tokens (e.g. impersonate) the endpoint will simply return the same token with the up-to-date user data associated with it._
|
||||
|
||||
- Added the triggered rate rimit rule in the error log `details`.
|
||||
|
||||
- Added optional `ServeEvent.Listener` field to initialize a custom network listener (e.g. `unix`) instead of the default `tcp` ([#3233](https://github.com/pocketbase/pocketbase/discussions/3233)).
|
||||
|
||||
- Fixed request data unmarshalization for the `DynamicModel` array/object fields ([#7022](https://github.com/pocketbase/pocketbase/discussions/7022)).
|
||||
|
||||
- Fixed Dashboard page title `-` escaping ([#6982](https://github.com/pocketbase/pocketbase/issues/6982)).
|
||||
|
||||
- Other minor improvements (updated first superuser console text when running with `go run`, clarified trusted IP proxy header label, wrapped the backup restore in a transaction as an extra precaution, updated deps, etc.).
|
||||
|
||||
|
||||
## v0.28.4
|
||||
|
||||
- Added global JSVM `toBytes()` helper to return the bytes slice representation of a value such as io.Reader or string, _other types are first serialized to Go string_ ([#6935](https://github.com/pocketbase/pocketbase/issues/6935)).
|
||||
|
||||
- Fixed `security.RandomStringByRegex` random distribution ([#6947](https://github.com/pocketbase/pocketbase/pull/6947); thanks @yerTools).
|
||||
|
||||
- Minor docs and typos fixes.
|
||||
|
||||
|
||||
## v0.28.3
|
||||
|
||||
- Skip sending empty `Range` header when fetching blobs from S3 ([#6914](https://github.com/pocketbase/pocketbase/pull/6914)).
|
||||
|
||||
- Updated Go deps and particularly `modernc.org/sqlite` to 1.38.0 (SQLite 3.50.1).
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.10 as it comes with some [minor security `net/http` fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.10+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.28.2
|
||||
|
||||
- Loaded latin-ext charset for the default text fonts ([#6869](https://github.com/pocketbase/pocketbase/issues/6869)).
|
||||
|
||||
- Updated view query CAST regex to properly recognize multiline expressions ([#6860](https://github.com/pocketbase/pocketbase/pull/6860); thanks @azat-ismagilov).
|
||||
|
||||
- Updated Go and npm dependencies.
|
||||
|
||||
|
||||
## v0.28.1
|
||||
|
||||
- Fixed `json_each`/`json_array_length` normalizations to properly check for array values ([#6835](https://github.com/pocketbase/pocketbase/issues/6835)).
|
||||
|
||||
|
||||
## v0.28.0
|
||||
|
||||
- Write the default response body of `*Request` hooks that are wrapped in a transaction after the related transaction completes to allow propagating the transaction error ([#6462](https://github.com/pocketbase/pocketbase/discussions/6462#discussioncomment-12207818)).
|
||||
|
||||
- Updated `app.DB()` to automatically routes raw write SQL statements to the nonconcurrent db pool ([#6689](https://github.com/pocketbase/pocketbase/discussions/6689)).
|
||||
_For the rare cases when it is needed users still have the option to explicitly target the specific pool they want using `app.ConcurrentDB()`/`app.NonconcurrentDB()`._
|
||||
|
||||
- ⚠️ Changed the default `json` field max size to 1MB.
|
||||
_Users still have the option to adjust the default limit from the collection field options but keep in mind that storing large strings/blobs in the database is known to cause performance issues and should be avoided when possible._
|
||||
|
||||
- ⚠️ Soft-deprecated and replaced `filesystem.System.GetFile(fileKey)` with `filesystem.System.GetReader(fileKey)` to avoid the confusion with `filesystem.File`.
|
||||
_The old method will still continue to work for at least until v0.29.0 but you'll get a console warning to replace it with `GetReader`._
|
||||
|
||||
- Added new `filesystem.System.GetReuploadableFile(fileKey, preserveName)` method to return an existing blob as a `*filesystem.File` value ([#6792](https://github.com/pocketbase/pocketbase/discussions/6792)).
|
||||
_This method could be useful in case you want to clone an existing Record file and assign it to a new Record (e.g. in a Record duplicate action)._
|
||||
|
||||
- Other minor improvements (updated the GitHub release min Go version to 1.23.9, updated npm and Go deps, etc.)
|
||||
|
||||
|
||||
## v0.27.2
|
||||
|
||||
- Added workers pool when cascade deleting record files to minimize _"thread exhaustion"_ errors ([#6780](https://github.com/pocketbase/pocketbase/discussions/6780)).
|
||||
|
||||
- Updated the `:excerpt` fields modifier to properly account for multibyte characters ([#6778](https://github.com/pocketbase/pocketbase/issues/6778)).
|
||||
|
||||
- Use `rowid` as count column for non-view collections to minimize the need of having the id field in a covering index ([#6739](https://github.com/pocketbase/pocketbase/discussions/6739))
|
||||
|
||||
|
||||
## v0.27.1
|
||||
|
||||
- Updated example `geoPoint` API preview body data.
|
||||
|
||||
- Added JSVM `new GeoPointField({ ... })` constructor.
|
||||
|
||||
- Added _partial_ WebP thumbs generation (_the thumbs will be stored as PNG_; [#6744](https://github.com/pocketbase/pocketbase/pull/6744)).
|
||||
|
||||
- Updated npm dev dependencies.
|
||||
|
||||
|
||||
## v0.27.0
|
||||
|
||||
- ⚠️ Moved the Create and Manage API rule checks out of the `OnRecordCreateRequest` hook finalizer, **aka. now all CRUD API rules are checked BEFORE triggering their corresponding `*Request` hook**.
|
||||
This was done to minimize the confusion regarding the firing order of the request operations, making it more predictable and consistent with the other record List/View/Update/Delete request actions.
|
||||
It could be a minor breaking change if you are relying on the old behavior and have a Go `tests.ApiScenario` that is testing a Create API rule failure and expect `OnRecordCreateRequest` to be fired. In that case for example you may have to update your test scenario like:
|
||||
```go
|
||||
tests.ApiScenario{
|
||||
Name: "Example test that checks a Create API rule failure"
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/example/records",
|
||||
...
|
||||
// old:
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
// new:
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
}
|
||||
```
|
||||
If you are having difficulties adjusting your code, feel free to open a [Q&A discussion](https://github.com/pocketbase/pocketbase/discussions) with the failing/problematic code sample.
|
||||
|
||||
- Added [new `geoPoint` field](https://pocketbase.io/docs/collections/#geopoint) for storing `{"lon":x,"lat":y}` geographic coordinates.
|
||||
In addition, a new [`geoDistance(lonA, lotA, lonB, lotB)` function](https://pocketbase.io/docs/api-rules-and-filters/#geodistancelona-lata-lonb-latb) was also implemented that could be used to apply an API rule or filter constraint based on the distance (in km) between 2 geo points.
|
||||
|
||||
- Updated the `select` field UI to accommodate better larger lists and RTL languages ([#4674](https://github.com/pocketbase/pocketbase/issues/4674)).
|
||||
|
||||
- Updated the mail attachments auto MIME type detection to use `gabriel-vasile/mimetype` for consistency and broader sniffing signatures support.
|
||||
|
||||
- Forced `text/javascript` Content-Type when serving `.js`/`.mjs` collection uploaded files with the `/api/files/...` endpoint ([#6597](https://github.com/pocketbase/pocketbase/issues/6597)).
|
||||
|
||||
- Added second optional JSVM `DateTime` constructor argument for specifying a default timezone as TZ identifier when parsing the date string as alternative to a fixed offset in order to better handle daylight saving time nuances ([#6688](https://github.com/pocketbase/pocketbase/discussions/6688)):
|
||||
```js
|
||||
// the same as with CET offset: new DateTime("2025-10-26 03:00:00 +01:00")
|
||||
new DateTime("2025-10-26 03:00:00", "Europe/Amsterdam") // 2025-10-26 02:00:00.000Z
|
||||
|
||||
// the same as with CEST offset: new DateTime("2025-10-26 01:00:00 +02:00")
|
||||
new DateTime("2025-10-26 01:00:00", "Europe/Amsterdam") // 2025-10-25 23:00:00.000Z
|
||||
```
|
||||
|
||||
- Soft-deprecated the `$http.send`'s `result.raw` field in favor of `result.body` that contains the response body as plain bytes slice to avoid the discrepancies between Go and the JSVM when casting binary data to string.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.37.0.
|
||||
|
||||
- Other minor improvements (_removed the superuser fields from the auth record create/update body examples, allowed programmatically updating the auth record password from the create/update hooks, fixed collections import error response, etc._).
|
||||
|
||||
|
||||
## v0.26.6
|
||||
|
||||
- Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.36.3.
|
||||
|
||||
|
||||
## v0.26.5
|
||||
|
||||
- Fixed canonical URI parts escaping when generating the S3 request signature ([#6654](https://github.com/pocketbase/pocketbase/issues/6654)).
|
||||
|
||||
|
||||
## v0.26.4
|
||||
|
||||
- Fixed `RecordErrorEvent.Error` and `CollectionErrorEvent.Error` sync with `ModelErrorEvent.Error` ([#6639](https://github.com/pocketbase/pocketbase/issues/6639)).
|
||||
|
||||
- Fixed logs details copy to clipboard action.
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.36.2.
|
||||
|
||||
|
||||
## v0.26.3
|
||||
|
||||
- Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
|
||||
|
||||
|
||||
## v0.26.2
|
||||
|
||||
- Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
|
||||
|
||||
|
||||
## v0.26.1
|
||||
|
||||
- Removed the wrapping of `io.EOF` error when reading files since currently `io.ReadAll` doesn't check for wrapped errors ([#6600](https://github.com/pocketbase/pocketbase/issues/6600)).
|
||||
|
||||
|
||||
## v0.26.0
|
||||
|
||||
- ⚠️ Replaced `aws-sdk-go-v2` and `gocloud.dev/blob` with custom lighter implementation ([#6562](https://github.com/pocketbase/pocketbase/discussions/6562)).
|
||||
As a side-effect of the dependency removal, the binary size has been reduced with ~10MB and builds ~30% faster.
|
||||
_Although the change is expected to be backward-compatible, I'd recommend to test first locally the new version with your S3 provider (if you use S3 for files storage and backups)._
|
||||
|
||||
- ⚠️ Prioritized the user submitted non-empty `createData.email` (_it will be unverified_) when creating the PocketBase user during the first OAuth2 auth.
|
||||
|
||||
- Load the request info context during password/OAuth2/OTP authentication ([#6402](https://github.com/pocketbase/pocketbase/issues/6402)).
|
||||
This could be useful in case you want to target the auth method as part of the MFA and Auth API rules.
|
||||
For example, to disable MFA for the OAuth2 auth could be expressed as `@request.context != "oauth2"` MFA rule.
|
||||
|
||||
- Added `store.Store.SetFunc(key, func(old T) new T)` to set/update a store value with the return result of the callback in a concurrent safe manner.
|
||||
|
||||
- Added `subscription.Message.WriteSSE(w, id)` for writing an SSE formatted message into the provided writer interface (_used mostly to assist with the unit testing_).
|
||||
|
||||
- Added `$os.stat(file)` JSVM helper ([#6407](https://github.com/pocketbase/pocketbase/discussions/6407)).
|
||||
|
||||
- Added log warning for `async` marked JSVM handlers and resolve when possible the returned `Promise` as fallback ([#6476](https://github.com/pocketbase/pocketbase/issues/6476)).
|
||||
|
||||
- Allowed calling `cronAdd`, `cronRemove` from inside other JSVM handlers ([#6481](https://github.com/pocketbase/pocketbase/discussions/6481)).
|
||||
|
||||
- Bumped the default request read and write timeouts to 5mins (_old 3mins_) to accommodate slower internet connections and larger file uploads/downloads.
|
||||
_If you want to change them you can modify the `OnServe` hook's `ServeEvent.ReadTimeout/WriteTimeout` fields as shown in [#6550](https://github.com/pocketbase/pocketbase/discussions/6550#discussioncomment-12364515)._
|
||||
|
||||
- Normalized the `@request.auth.*` and `@request.body.*` back relations resolver to always return `null` when the relation field is pointing to a different collection ([#6590](https://github.com/pocketbase/pocketbase/discussions/6590#discussioncomment-12496581)).
|
||||
|
||||
- Other minor improvements (_fixed query dev log nested parameters output, reintroduced `DynamicModel` object/array props reflect types caching, updated Go and npm deps, etc._)
|
||||
|
||||
|
||||
## v0.25.9
|
||||
|
||||
- Fixed `DynamicModel` object/array props reflect type caching ([#6563](https://github.com/pocketbase/pocketbase/discussions/6563)).
|
||||
|
||||
|
||||
## v0.25.8
|
||||
|
||||
- Added a default leeway of 5 minutes for the Apple/OIDC `id_token` timestamp claims check to account for clock-skew ([#6529](https://github.com/pocketbase/pocketbase/issues/6529)).
|
||||
It can be further customized if needed with the `PB_ID_TOKEN_LEEWAY` env variable (_the value must be in seconds, e.g. "PB_ID_TOKEN_LEEWAY=60" for 1 minute_).
|
||||
|
||||
|
||||
## v0.25.7
|
||||
|
||||
- Fixed `@request.body.jsonObjOrArr.*` values extraction ([#6493](https://github.com/pocketbase/pocketbase/discussions/6493)).
|
||||
|
||||
|
||||
## v0.25.6
|
||||
|
||||
- Restore the missing `meta.isNew` field of the OAuth2 success response ([#6490](https://github.com/pocketbase/pocketbase/issues/6490)).
|
||||
|
||||
- Updated npm dependencies.
|
||||
|
||||
|
||||
## v0.25.5
|
||||
|
||||
- Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||
|
||||
- Updated `modernc.org/sqlite` and `modernc.org/libc` dependencies.
|
||||
|
||||
|
||||
## v0.25.4
|
||||
|
||||
- Downgraded `aws-sdk-go-v2` to the version before the default data integrity checks because there have been reports for non-AWS S3 providers in addition to Backblaze (IDrive, R2) that no longer or partially work with the latest AWS SDK changes.
|
||||
@@ -139,7 +749,7 @@
|
||||
|
||||
- Eagerly interrupt waiting for the email alert send in case it takes longer than 15s.
|
||||
|
||||
- Normalized the hidden fields filter checks and allow targetting hidden fields in the List API rule.
|
||||
- Normalized the hidden fields filter checks and allow targeting hidden fields in the List API rule.
|
||||
|
||||
- Fixed "Unique identify fields" input not refreshing on unique indexes change ([#6184](https://github.com/pocketbase/pocketbase/issues/6184)).
|
||||
|
||||
@@ -231,7 +841,7 @@
|
||||
- Added support for passing more than one id in the `Hook.Unbind` method for consistency with the router.
|
||||
|
||||
- Added collection rules change list in the confirmation popup
|
||||
(_to avoid getting anoying during development, the rules confirmation currently is enabled only when using https_).
|
||||
(_to avoid getting annoying during development, the rules confirmation currently is enabled only when using https_).
|
||||
|
||||
|
||||
## v0.23.1
|
||||
@@ -274,14 +884,14 @@ There are a lot of changes but to highlight some of the most notable ones:
|
||||
- Option to specify custom `DBConnect` function as part of the app configuration to allow different `database/sql` SQLite drivers (_turso/libsql, sqlcipher, etc._) and custom builds.
|
||||
_Note that we no longer loads the `mattn/go-sqlite3` driver by default when building with `CGO_ENABLED=1` to avoid `multiple definition` linker errors in case different CGO SQLite drivers or builds are used. You can find an example how to enable it back if you want to in the [new documentation](https://pocketbase.io/docs/go-overview/#github-commattngo-sqlite3)._
|
||||
- New hooks allowing better control over the execution chain and error handling (_including wrapping an entire hook chain in a single DB transaction_).
|
||||
- Various `Record` model improvements (_support for get/set modifiers, simplfied file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||
- Various `Record` model improvements (_support for get/set modifiers, simplified file upload by treating the file(s) as regular field value like `record.Set("document", file)`, etc._).
|
||||
- Dedicated fields structs with safer defaults to make it easier creating/updating collections programmatically.
|
||||
- Option to mark field as "Hidden", disallowing regular users to read or modify it (_there is also a dedicated Record hook to hide/unhide Record fields programmatically from a single place_).
|
||||
- Option to customize the default system collection fields (`id`, `email`, `password`, etc.).
|
||||
- Admins are now system `_superusers` auth records.
|
||||
- Builtin rate limiter (_supports tags, wildcards and exact routes matching_).
|
||||
- Batch/transactional Web API endpoint.
|
||||
- Impersonate Web API endpoint (_it could be also used for generating fixed/non-refreshable superuser tokens, aka. "API keys"_).
|
||||
- Impersonate Web API endpoint (_it could be also used for generating fixed/nonrenewable superuser tokens, aka. "API keys"_).
|
||||
- Support for custom user request activity log attributes.
|
||||
- One-Time Password (OTP) auth method (_via email code_).
|
||||
- Multi-Factor Authentication (MFA) support (_currently requires any 2 different auth methods to be used_).
|
||||
|
||||
+69
-3
@@ -2,6 +2,72 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.41
|
||||
|
||||
- (_Backported from v0.36.9_) Updated the Discord `AuthUser.Name` field to use `global_name`.
|
||||
|
||||
- (_Backported from v0.36.9_) Updated `modernc.org/sqlite` to v1.48.2 _(vfs and other error path related fixes)_.
|
||||
|
||||
- (_Backported from v0.36.9_) Bumped min Go GitHub action version to 1.26.2 because it comes with several [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2).
|
||||
|
||||
|
||||
## v0.22.40
|
||||
|
||||
- (_Backported from v0.36.7_) Updated `modernc.org/sqlite` to v1.46.2 and SQLite 3.51.3.
|
||||
_⚠️ SQLite 3.51.3 fixed a [database corruption bug](https://sqlite.org/wal.html#walresetbug) that is very unlikely to happen (with PocketBase even more so because we queue on app level all writes and explicit transactions through a single db connection), but still it is advised to upgrade._
|
||||
|
||||
- (_Backported from v0.36.7_) Updated other minor Go and npm deps.
|
||||
_The min Go version in the go.mod of the package was also bumped to Go 1.25.0 because some of the newer dep versions require it._
|
||||
|
||||
|
||||
## v0.22.39
|
||||
|
||||
- (_Backported from v0.36.6_) Bumped min Go GitHub action version to 1.26.1 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.1).
|
||||
|
||||
|
||||
## v0.22.38
|
||||
|
||||
- (_Backported from v0.36.0_) Bumped min Go GitHub action version to 1.25.6 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.6).
|
||||
|
||||
|
||||
## v0.22.37
|
||||
|
||||
- (_Backported from v0.34.1_) - Added missing `:` char to the autocomplete regex ([#7353](https://github.com/pocketbase/pocketbase/pull/7353)).
|
||||
|
||||
- (_Backported from v0.34.1_) Bumped min Go GitHub action version to 1.25.5 because it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.25.5).
|
||||
_The runner action was also updated to `actions/setup-go@v6` since the previous v5 Go source seems [no longer accessible](https://github.com/actions/setup-go/pull/665#issuecomment-3416693714)._
|
||||
|
||||
|
||||
## v0.22.36
|
||||
|
||||
- (_Backported from v0.30.2_) Bumped min Go GitHub action version to 1.24.8 since it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.24.8+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.22.35
|
||||
|
||||
- (_Backported from v0.29.2_) Bumped min Go GitHub action version to 1.23.12 since it comes with some [minor fixes for the runtime and `database/sql` package](https://github.com/golang/go/issues?q=milestone%3AGo1.23.12+label%3ACherryPickApproved).
|
||||
|
||||
|
||||
## v0.22.34
|
||||
|
||||
- (_Backported from v0.26.6_) Allow OIDC `email_verified` to be int or boolean string since some OIDC providers like AWS Cognito has non-standard userinfo response ([#6657](https://github.com/pocketbase/pocketbase/pull/6657)).
|
||||
|
||||
|
||||
## v0.22.33
|
||||
|
||||
- (_Backported from v0.26.3_) Fixed and normalized logs error serialization across common types for more consistent logs error output ([#6631](https://github.com/pocketbase/pocketbase/issues/6631)).
|
||||
|
||||
|
||||
## v0.22.32
|
||||
|
||||
- (_Backported from v0.26.2_) Updated `golang-jwt/jwt` dependency because it comes with a [minor security fix](https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp).
|
||||
|
||||
|
||||
## v0.22.31
|
||||
|
||||
- (_Backported from v0.25.5_) Set the current working directory as a default goja script path when executing inline JS strings to allow `require(m)` traversing parent `node_modules` directories.
|
||||
|
||||
|
||||
## v0.22.30
|
||||
|
||||
- (_Backported from v0.24.4_) Fixed fields extraction for view queries with nested comments ([#6309](https://github.com/pocketbase/pocketbase/discussions/6309)).
|
||||
@@ -37,7 +103,7 @@
|
||||
|
||||
- Refresh the old collections state in the Import UI after successful import submission ([#5861](https://github.com/pocketbase/pocketbase/issues/5861)).
|
||||
|
||||
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibity of eventual side-channel attacks.
|
||||
- Added randomized throttle on failed filter list requests as a very rudimentary measure since some security researches raised concern regarding the possibility of eventual side-channel attacks.
|
||||
|
||||
|
||||
## v0.22.24
|
||||
@@ -439,7 +505,7 @@
|
||||
A negative or zero value means no tests timeout.
|
||||
If a single API test takes more than 3s to complete it will have a log message visible when the test fails or when `go test -v` flag is used.
|
||||
|
||||
- Added timestamp at the beginning of the generated JSVM types file to avoid creating it everytime with the app startup.
|
||||
- Added timestamp at the beginning of the generated JSVM types file to avoid creating it every time with the app startup.
|
||||
|
||||
|
||||
## v0.20.0
|
||||
@@ -819,7 +885,7 @@
|
||||
|
||||
- ⚠️ Deprecated `RelationOptions.DisplayFields` in favor of the new `SchemaField.Presentable` option to avoid the duplication when a single collection is referenced more than once and/or by multiple other collections.
|
||||
|
||||
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
|
||||
- ⚠️ Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successful email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)).
|
||||
|
||||
- ⚠️ Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)).
|
||||
|
||||
|
||||
+2
-2
@@ -326,7 +326,7 @@
|
||||
|
||||
- Added "tags" support for all Record and Model related event hooks.
|
||||
|
||||
The "tags" allow registering event handlers that will be called only on matching table name(s) or colleciton id(s)/name(s).
|
||||
The "tags" allow registering event handlers that will be called only on matching table name(s) or collection id(s)/name(s).
|
||||
For example:
|
||||
```go
|
||||
app.OnRecordBeforeCreateRequest("articles").Add(func(e *core.RecordCreateEvent) error {
|
||||
@@ -840,7 +840,7 @@ Please check the individual SDK package changelog and apply the necessary change
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- All datetime stings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
|
||||
- All datetime strings are now returned in ISO8601 format - with _Z_ suffix and space as separator between the date and time part:
|
||||
<table class="d-table" width="100%">
|
||||
<tr>
|
||||
<th>Old</th>
|
||||
|
||||
+16
-13
@@ -1,17 +1,20 @@
|
||||
# Contributing to PocketBase
|
||||
|
||||
Thanks for taking the time to improve PocketBase!
|
||||
> [!IMPORTANT]
|
||||
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
|
||||
> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
|
||||
> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
|
||||
|
||||
This document describes how to prepare a PR for a change in the main repository.
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
- [Making changes in the Superuser UI](#making-changes-in-the-admin-ui)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.23+ (for making changes in the Go code)
|
||||
- Node 18+ (for making changes in the Admin UI)
|
||||
- Go 1.25+ (for making changes in the Go code)
|
||||
- Node 24+ (for making changes in the Superuser UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
@@ -34,7 +37,7 @@ So, let's assume that you already done some changes in the PocketBase Go code an
|
||||
1. Navigate to `examples/base`
|
||||
2. Run `go run main.go serve`
|
||||
|
||||
This will start a web server on `http://localhost:8090` with the embedded prebuilt Admin UI from `ui/dist`. And that's it!
|
||||
This will start a web server on `http://localhost:8090` with the embedded prebuilt Superuser UI from `ui/dist`. And that's it!
|
||||
|
||||
**Before making a PR to the main repository, it is a good idea to:**
|
||||
|
||||
@@ -57,11 +60,11 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
## Making changes in the Superuser UI
|
||||
|
||||
PocketBase Admin UI is a single-page application (SPA) built with Svelte and Vite.
|
||||
PocketBase Superuser UI is a single-page application (SPA) built with Svelte and Vite.
|
||||
|
||||
To start the Admin UI:
|
||||
To start the Superuser UI:
|
||||
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
@@ -70,13 +73,13 @@ To start the Admin UI:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
You could open the browser and access the running Superuser UI at `http://localhost:5173`.
|
||||
|
||||
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).
|
||||
Since the Superuser 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.
|
||||
> By default, the Superuser 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.
|
||||
Every change you make in the Superuser UI should be automatically reflected in the browser at `http://localhost:5173` without reloading the page.
|
||||
|
||||
Once you are done with your changes, you have to build the Admin UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
|
||||
Once you are done with your changes, you have to build the Superuser UI with `npm run build`, so that it can be embedded in the go package. And that's it - you can make your PR to the main PocketBase repository.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<a href="https://pocketbase.io" target="_blank" rel="noopener">
|
||||
<img src="https://i.imgur.com/5qimnm5.png" alt="PocketBase - open source backend in 1 file" />
|
||||
<img src="https://i.imgur.com/aCBbjKx.png" alt="PocketBase - open source backend in 1 file" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -49,7 +49,7 @@ your own custom app specific business logic and still have a single portable exe
|
||||
|
||||
Here is a minimal example:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
|
||||
1. Create a new project directory with the following `main.go` file inside it:
|
||||
```go
|
||||
@@ -92,7 +92,7 @@ _For more details please refer to [Extend with Go](https://pocketbase.io/docs/go
|
||||
|
||||
To build the minimal standalone executable, like the prebuilt ones in the releases page, you can simply run `go build` inside the `examples/base` directory:
|
||||
|
||||
0. [Install Go 1.23+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.25+](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`
|
||||
@@ -110,9 +110,11 @@ linux 386
|
||||
linux amd64
|
||||
linux arm
|
||||
linux arm64
|
||||
linux loong64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows 386
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
@@ -144,10 +146,13 @@ You could help continuing its development by:
|
||||
- [Contribute to the source code](CONTRIBUTING.md)
|
||||
- [Suggest new features and report issues](https://github.com/pocketbase/pocketbase/issues)
|
||||
|
||||
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
|
||||
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).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Due to recent LLM spam, PRs are temporary disabled and only existing collaborators can open a PR.
|
||||
> If you stumble on a problem that you want to fix, please consider instead opening an issue or discussion with link to your fork _(if not obvious - LLM contributions are not welcome)_.
|
||||
> This status may change in the future in case GitHub finally decide to do something about the constant spam, or when I find time to move the project somewhere else.
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"gocloud.dev/blob"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem/blob"
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
@@ -490,7 +490,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
|
||||
+7
-3
@@ -15,7 +15,7 @@ import (
|
||||
// StaticWildcardParam is the name of Static handler wildcard parameter.
|
||||
const StaticWildcardParam = "path"
|
||||
|
||||
// NewRouter returns a new router instance loaded with the default app middlewares and api routes.
|
||||
// NewRouter returns a new router instance loaded with the default app middlewares and routes.
|
||||
func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
pbRouter := router.NewRouter(func(w http.ResponseWriter, r *http.Request) (*core.RequestEvent, router.EventCleanupFunc) {
|
||||
event := new(core.RequestEvent)
|
||||
@@ -34,6 +34,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
pbRouter.Bind(securityHeaders())
|
||||
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
||||
|
||||
// API routes
|
||||
apiGroup := pbRouter.Group("/api")
|
||||
bindSettingsApi(app, apiGroup)
|
||||
bindCollectionApi(app, apiGroup)
|
||||
@@ -47,6 +48,9 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
bindHealthApi(app, apiGroup)
|
||||
|
||||
// UI routes
|
||||
bindUIExtensions(app)
|
||||
|
||||
return pbRouter, nil
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
|
||||
|
||||
// Static is a handler function to serve static directory content from fsys.
|
||||
//
|
||||
// If a file resource is missing and indexFallback is set, the request
|
||||
// If a file resource is missing and indexFallback is true, the request
|
||||
// will be forwarded to the base index.html (useful for SPA with pretty urls).
|
||||
//
|
||||
// NB! Expects the route to have a "{path...}" wildcard parameter.
|
||||
@@ -94,7 +98,7 @@ func MustSubFS(fsys fs.FS, dir string) fs.FS {
|
||||
// Special redirects:
|
||||
// - if "path" is a file that ends in index.html, it is redirected to its non-index.html version (eg. /test/index.html -> /test/)
|
||||
// - if "path" is a directory that has index.html, the index.html file is rendered,
|
||||
// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is set
|
||||
// otherwise if missing - returns 404 or fallback to the root index.html if indexFallback is true
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
|
||||
+7
-6
@@ -49,7 +49,7 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
|
||||
params["id"] = id // required for the path value
|
||||
ir.Method = "PATCH"
|
||||
ir.URL = "/api/collections/" + params["collection"] + "/records/" + id + params["query"]
|
||||
return recordUpdate(next)
|
||||
return recordUpdate(false, next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,16 +57,16 @@ var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
|
||||
// ---
|
||||
ir.Method = "POST"
|
||||
ir.URL = "/api/collections/" + params["collection"] + "/records" + params["query"]
|
||||
return recordCreate(next)
|
||||
return recordCreate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^POST /api/collections/(?P<collection>[^\/\?]+)/records(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordCreate(next)
|
||||
return recordCreate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^PATCH /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordUpdate(next)
|
||||
return recordUpdate(false, next)
|
||||
},
|
||||
regexp.MustCompile(`^DELETE /api/collections/(?P<collection>[^\/\?]+)/records/(?P<id>[^\/\?]+)(\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
return recordDelete(next)
|
||||
return recordDelete(false, next)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (brs batchRequestsForm) validate() error {
|
||||
}
|
||||
|
||||
// NB! When the request is submitted as multipart/form-data,
|
||||
// the regular fields data is expected to be submitted as serailized
|
||||
// the regular fields data is expected to be submitted as serialized
|
||||
// json under the @jsonPayload field and file keys need to follow the
|
||||
// pattern "requests.N.fileField" or requests[N].fileField.
|
||||
func batchTransaction(e *core.RequestEvent) error {
|
||||
@@ -364,6 +364,7 @@ func processInternalRequest(
|
||||
// assign request
|
||||
event.Request = r
|
||||
event.Request.Body = &router.RereadableReadCloser{ReadCloser: r.Body} // enables multiple reads
|
||||
defer event.Request.Body.Close()
|
||||
|
||||
// assign response
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
+109
-9
@@ -3,12 +3,15 @@ package apis
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
|
||||
@@ -22,6 +25,10 @@ func bindCollectionApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
|
||||
subGroup.DELETE("/{collection}/truncate", collectionTruncate)
|
||||
subGroup.PUT("/import", collectionsImport)
|
||||
subGroup.GET("/meta/scaffolds", collectionScaffolds)
|
||||
|
||||
// @todo experimental
|
||||
subGroup.GET("/meta/oauth2-providers", collectionListOAuth2Providers)
|
||||
subGroup.POST("/meta/dry-run-view", collectionDryRunView)
|
||||
}
|
||||
|
||||
func collectionsList(e *core.RequestEvent) error {
|
||||
@@ -45,7 +52,9 @@ func collectionsList(e *core.RequestEvent) error {
|
||||
event.Result = result
|
||||
|
||||
return event.App.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +69,9 @@ func collectionView(e *core.RequestEvent) error {
|
||||
event.Collection = collection
|
||||
|
||||
return e.App.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,7 +109,9 @@ func collectionCreate(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Failed to create collection. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +141,9 @@ func collectionUpdate(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Failed to update collection. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,7 +174,9 @@ func collectionDelete(e *core.RequestEvent) error {
|
||||
return e.BadRequestError(msg, err)
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,15 +199,98 @@ func collectionTruncate(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
func collectionScaffolds(e *core.RequestEvent) error {
|
||||
randomId := security.RandomStringWithAlphabet(10, core.DefaultIdAlphabet) // could be used as part of the default indexes name
|
||||
|
||||
collections := map[string]*core.Collection{
|
||||
core.CollectionTypeBase: core.NewBaseCollection(""),
|
||||
core.CollectionTypeAuth: core.NewAuthCollection(""),
|
||||
core.CollectionTypeView: core.NewViewCollection(""),
|
||||
core.CollectionTypeBase: core.NewBaseCollection("", randomId),
|
||||
core.CollectionTypeAuth: core.NewAuthCollection("", randomId),
|
||||
core.CollectionTypeView: core.NewViewCollection("", randomId),
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
c.Id = "" // clear autogenerated id
|
||||
c.Id = "" // clear random id
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, collections)
|
||||
}
|
||||
|
||||
type providerListItem struct {
|
||||
order int
|
||||
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Logo string `json:"logo"`
|
||||
}
|
||||
|
||||
func collectionListOAuth2Providers(e *core.RequestEvent) error {
|
||||
providers := make([]*providerListItem, 0, len(auth.Providers))
|
||||
|
||||
for name, factory := range auth.Providers {
|
||||
p := factory()
|
||||
|
||||
providers = append(providers, &providerListItem{
|
||||
order: p.Order(),
|
||||
Name: name,
|
||||
DisplayName: p.DisplayName(),
|
||||
Logo: p.Logo(),
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortStableFunc(providers, func(a, b *providerListItem) int {
|
||||
// sort by order
|
||||
if a.order < b.order {
|
||||
return -1
|
||||
}
|
||||
if a.order > b.order {
|
||||
return 1
|
||||
}
|
||||
|
||||
// fallback sort by name
|
||||
if a.Name < b.Name {
|
||||
return -1
|
||||
}
|
||||
if a.Name > b.Name {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return e.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
func collectionDryRunView(e *core.RequestEvent) error {
|
||||
// extra precaution in case reused in custom route group
|
||||
if !e.HasSuperuserAuth() {
|
||||
return e.ForbiddenError("", nil)
|
||||
}
|
||||
|
||||
form := dryRunViewForm{}
|
||||
|
||||
err := e.BindBody(&form)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
err = form.validate()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
result, err := e.App.DryRunView(form.Query, 10)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Invalid view query. Raw error: \n"+err.Error(), nil))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
type dryRunViewForm struct {
|
||||
Query string `form:"query" json:"query"`
|
||||
}
|
||||
|
||||
func (form *dryRunViewForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,12 +29,14 @@ func collectionsImport(e *core.RequestEvent) error {
|
||||
return event.App.OnCollectionsImportRequest().Trigger(event, func(e *core.CollectionsImportRequestEvent) error {
|
||||
importErr := e.App.ImportCollections(e.CollectionsData, form.DeleteMissing)
|
||||
if importErr == nil {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// validation failure
|
||||
var validationErrors validation.Errors
|
||||
if errors.As(err, &validationErrors) {
|
||||
if errors.As(importErr, &validationErrors) {
|
||||
return e.BadRequestError("Failed to import collections.", validationErrors)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -88,6 +89,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
`import2`,
|
||||
`fields`,
|
||||
},
|
||||
NotExpectedContent: []string{"Raw error:"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionsImportRequest": 1,
|
||||
@@ -109,6 +111,71 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + non-validator failure",
|
||||
Method: http.MethodPut,
|
||||
URL: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"collections":[
|
||||
{
|
||||
"name": "import1",
|
||||
"fields": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"fields": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"collections":{"code":"validation_collections_import_failure"`,
|
||||
`Raw error:`,
|
||||
`custom_error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionsImportRequest": 1,
|
||||
"OnCollectionCreate": 1,
|
||||
"OnCollectionAfterCreateError": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelAfterCreateError": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionCreate().BindFunc(func(e *core.CollectionEvent) error {
|
||||
return errors.New("custom_error")
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*core.Collection{}
|
||||
if err := app.CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := totalCollections
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + successful collections create",
|
||||
Method: http.MethodPut,
|
||||
@@ -249,6 +316,51 @@ func TestCollectionsImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionsImportRequest tx body write check",
|
||||
Method: http.MethodPut,
|
||||
URL: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"deleteMissing": true,
|
||||
"collections":[
|
||||
{"name": "test123"},
|
||||
{
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo1",
|
||||
"fields":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
"name":"title",
|
||||
"type":"text",
|
||||
"required":true
|
||||
}
|
||||
],
|
||||
"indexes": []
|
||||
}
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionsImportRequest().BindFunc(func(e *core.CollectionsImportRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionsImportRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+284
-28
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -130,6 +129,32 @@ func TestCollectionsList(t *testing.T) {
|
||||
"OnCollectionsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionsListRequest().BindFunc(func(e *core.CollectionsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -205,6 +230,32 @@ func TestCollectionView(t *testing.T) {
|
||||
"OnCollectionViewRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionViewRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/wsmn24bux7wo113",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionViewRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnCollectionViewRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -361,7 +412,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterDeleteSuccessRequest error response",
|
||||
Name: "OnCollectionDeleteRequest tx body write check",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/view2",
|
||||
Headers: map[string]string{
|
||||
@@ -369,15 +420,22 @@ func TestCollectionDelete(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionDeleteRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionDeleteRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionDeleteRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -478,7 +536,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
// ensures that id field was prepended
|
||||
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
|
||||
`"fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"12345789","max":0,"min":0,"name":"test","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"}]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -527,7 +585,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"verified"`,
|
||||
`"duration":123`,
|
||||
// should overwrite the user required option but keep the min value
|
||||
`{"autogeneratePattern":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
|
||||
`{"autogeneratePattern":"","help":"","hidden":true,"id":"text2504183744","max":0,"min":10,"name":"tokenKey","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":true,"type":"text"}`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"secret":"`,
|
||||
@@ -656,7 +714,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionCreateRequest error response",
|
||||
Name: "OnCollectionCreateRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base"}`),
|
||||
@@ -665,15 +723,22 @@ func TestCollectionCreate(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionCreateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionCreateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// view
|
||||
@@ -686,7 +751,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery":"invalid"
|
||||
"viewQuery":"select '123' as abc"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -715,7 +780,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"name":"new",
|
||||
"type":"view",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + `"
|
||||
"viewQuery": "select 1 as id from ` + core.CollectionNameSuperusers + ` limit 1"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -724,7 +789,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"view"`,
|
||||
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
`"fields":[{"autogeneratePattern":"","help":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"}]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -978,7 +1043,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterUpdateSuccessRequest error response",
|
||||
Name: "OnCollectionUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
@@ -987,15 +1052,22 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnCollectionUpdateRequest().BindFunc(func(e *core.CollectionRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnCollectionUpdateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnCollectionUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser + invalid data (eg. existing name)",
|
||||
@@ -1190,7 +1262,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
Body: strings.NewReader(`{
|
||||
"name":"view2_update",
|
||||
"fields":[{"type":"text","id":"12345789","name":"ignored!@#$"}],
|
||||
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + `"
|
||||
"viewQuery": "select 2 as id, created, updated, email from ` + core.CollectionNameSuperusers + ` limit 1"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
@@ -1512,3 +1584,187 @@ func TestCollectionTruncate(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionOAuth2Providers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/oauth2-providers",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`{"name":"oidc3","displayName":"OIDC","logo":"\u003csvg`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"order":`,
|
||||
`"pkce":`,
|
||||
`"scopes":`,
|
||||
`"authURL":`,
|
||||
`"tokenURL":`,
|
||||
`"userInfoURL":`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionTestView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"fields":[{`,
|
||||
`"name":"id"`,
|
||||
`"type":"text"`,
|
||||
`"sample":[{`,
|
||||
`"id":"1"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "empty query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":""}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{"query":`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query length beyond validator limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{"query":`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query with length equal to the validator limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"select 1 as id` + strings.Repeat(" ", 4986) + `"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"fields":[{`,
|
||||
`"name":"id"`,
|
||||
`"type":"text"`,
|
||||
`"sample":[`,
|
||||
`"id":"1"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "missing ids sample",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"(select 1 as id union select '' as id)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicated ids sample",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"(select 1 as id union all select 1 as id)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/meta/dry-run-view",
|
||||
Body: strings.NewReader(`{"query":"CREATE TABLE t1(x INT)"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
// bindUIExtensions binds the superuser UI extensions routes to the ServeEvent.Router.
|
||||
//
|
||||
// This method does nothing if the superuser UI is not bundled (aka. build with "no_ui" tag),
|
||||
func bindUIExtensions(app core.App) {
|
||||
if ui.DistDirFS == nil {
|
||||
return
|
||||
}
|
||||
|
||||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Priority: 9999, // execute as latest as possible
|
||||
Func: func(se *core.ServeEvent) error {
|
||||
uiGroup := se.Router.Group("/_").
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
if !e.App.IsDev() && e.Response.Header().Get("Cache-Control") == "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
|
||||
// register static extension routes
|
||||
for _, ext := range se.UIExtensions {
|
||||
if ext.Name == "" || ext.FS == nil {
|
||||
se.App.Logger().Debug("Invalid UI extension configuration", slog.Any("extension", ext))
|
||||
continue
|
||||
}
|
||||
|
||||
uiGroup.GET("/extensions/"+ext.Name+"/{path...}", Static(ext.FS, false))
|
||||
}
|
||||
|
||||
// combine all extensions main.js in one file
|
||||
//
|
||||
// note: don't cache in memory to allow previewing changes without restart
|
||||
uiGroup.GET("/extensions.js", func(re *core.RequestEvent) error {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
for _, ext := range se.UIExtensions {
|
||||
err := copyExtensionMainjs(buf, ext)
|
||||
if err != nil {
|
||||
return re.InternalServerError("An error occurred while generating the main.js extension file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return re.Stream(200, "text/javascript", buf)
|
||||
}).Bind(SkipSuccessActivityLog())
|
||||
|
||||
return se.Next()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func copyExtensionMainjs(buf *bytes.Buffer, ext core.UIExtension) error {
|
||||
f, err := ext.FS.Open("main.js")
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil // nothing to copy
|
||||
}
|
||||
|
||||
return fmt.Errorf("[UI extension %q] main.js open error: %w", ext.Name, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// wrap in a self-executing function to avoid scope and concatenation issues
|
||||
_, _ = buf.WriteString("(function(){")
|
||||
|
||||
_, err = io.Copy(buf, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[UI extension %q] main.js copy error: %w", ext.Name, err)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString("})();")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
|
||||
func TestUIExtensions_Mainjs(t *testing.T) {
|
||||
successAfterTestFunc := func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
expected := "text/javascript"
|
||||
if ct := res.Header.Get("content-type"); ct != expected {
|
||||
t.Fatalf("Expected response Content-Type %q, got %q", expected, ct)
|
||||
}
|
||||
}
|
||||
|
||||
oldDistDirFS := ui.DistDirFS
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "disabled UI",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// simulate no_ui tag (needs to be cleared before the router is initialized)
|
||||
ui.DistDirFS = nil
|
||||
|
||||
return app
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
ui.DistDirFS = oldDistDirFS
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "no extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
AfterTestFunc: successAfterTestFunc,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions.js",
|
||||
TestAppFactory: func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
e.UIExtensions = createTestExtensions()
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
return app
|
||||
},
|
||||
AfterTestFunc: successAfterTestFunc,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"(function(){ext1_main})();(function(){ext3_main})();"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't run in parallel to avoid conflicts with the ui.DistDirFS nil test
|
||||
func TestUIExtensions_Files(t *testing.T) {
|
||||
testAppFactory := func(t testing.TB) *tests.TestApp {
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||
e.UIExtensions = createTestExtensions()
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "no extensions",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/test.txt",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with missing extension file",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/missing",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with existing extension file (ext1)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext1/test.txt",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"ext1_txt"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with existing extension file (extension name escape)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/_/extensions/ext3%20with%20spaces/test.txt",
|
||||
TestAppFactory: testAppFactory,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"ext3_txt"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestExtensions() []core.UIExtension {
|
||||
return []core.UIExtension{
|
||||
{
|
||||
Name: "ext1",
|
||||
FS: fstest.MapFS{
|
||||
"main.js": &fstest.MapFile{
|
||||
Data: []byte("ext1_main"),
|
||||
},
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext1_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ext2",
|
||||
FS: fstest.MapFS{
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext2_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ext3 with spaces",
|
||||
FS: fstest.MapFS{
|
||||
"main.js": &fstest.MapFile{
|
||||
Data: []byte("ext3_main"),
|
||||
},
|
||||
"test.txt": &fstest.MapFile{
|
||||
Data: []byte("ext3_txt"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+27
-21
@@ -19,7 +19,7 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"}
|
||||
var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
@@ -75,8 +75,8 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
||||
event.Record = e.Auth
|
||||
|
||||
return e.App.OnFileTokenRequest().Trigger(event, func(e *core.FileTokenRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"token": e.Token})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -142,8 +142,14 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
defer fsys.Close()
|
||||
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
servedName := filename
|
||||
|
||||
event := new(core.FileDownloadRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = originalPath
|
||||
event.ServedName = filename
|
||||
|
||||
// check for valid thumb size param
|
||||
thumbSize := e.Request.URL.Query().Get("thumb")
|
||||
@@ -157,34 +163,31 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
// check if it is an image
|
||||
if list.ExistInSlice(oAttrs.ContentType, imageContentTypes) {
|
||||
// add thumb size as file suffix
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
event.ServedName = thumbSize + "_" + filename
|
||||
event.ServedPath = baseFilesPath + "/thumbs_" + filename + "/" + event.ServedName
|
||||
|
||||
// create a new thumb if it doesn't exist
|
||||
if exists, _ := fsys.Exists(servedPath); !exists {
|
||||
if err := api.createThumb(e, fsys, originalPath, servedPath, thumbSize); err != nil {
|
||||
if exists, _ := fsys.Exists(event.ServedPath); !exists {
|
||||
if err := api.createThumb(e, fsys, originalPath, event.ServedPath, thumbSize); err != nil {
|
||||
e.App.Logger().Warn(
|
||||
"Fallback to original - failed to create thumb "+servedName,
|
||||
"Fallback to original - failed to create thumb "+event.ServedName,
|
||||
slog.Any("error", err),
|
||||
slog.String("original", originalPath),
|
||||
slog.String("thumb", servedPath),
|
||||
slog.String("thumb", event.ServedPath),
|
||||
)
|
||||
|
||||
// fallback to the original
|
||||
servedName = filename
|
||||
servedPath = originalPath
|
||||
event.ThumbError = err
|
||||
event.ServedName = filename
|
||||
event.ServedPath = originalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.FileDownloadRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
event.FileField = fileField
|
||||
event.ServedPath = servedPath
|
||||
event.ServedName = servedName
|
||||
if thumbSize != "" && event.ThumbError == nil && event.ServedPath == originalPath {
|
||||
event.ThumbError = fmt.Errorf("the thumb size %q or the original file format are not supported", thumbSize)
|
||||
}
|
||||
|
||||
// clickjacking shouldn't be a concern when serving uploaded files,
|
||||
// so it safe to unset the global X-Frame-Options to allow files embedding
|
||||
@@ -192,7 +195,10 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
|
||||
return e.App.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadRequestEvent) error {
|
||||
if err := fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName); err != nil {
|
||||
err = execAfterSuccessTx(true, e.App, func() error {
|
||||
return fsys.Serve(e.Response, e.Request, e.ServedPath, e.ServedName)
|
||||
})
|
||||
if err != nil {
|
||||
return e.NotFoundError("", err)
|
||||
}
|
||||
|
||||
|
||||
+88
-24
@@ -181,9 +181,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - missing thumb (should fallback to the original)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
Name: "existing image - missing thumb (should fallback to the original)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError == nil {
|
||||
t.Fatal("Expected thumb error, got nil")
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testImg)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -192,9 +200,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
Name: "existing image - existing thumb (crop center)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropCenter)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -203,9 +219,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
Name: "existing image - existing thumb (crop top)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropTop)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -214,9 +238,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
Name: "existing image - existing thumb (crop bottom)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbCropBottom)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -225,9 +257,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
Name: "existing image - existing thumb (fit)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbFit)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -236,9 +276,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
Name: "existing image - existing thumb (zero width)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroWidth)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -247,9 +295,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
Name: "existing image - existing thumb (zero height)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError != nil {
|
||||
t.Fatalf("Expected no thumb error, got %v", e.ThumbError)
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testThumbZeroHeight)},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -258,9 +314,17 @@ func TestFileDownload(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing non image file - thumb parameter should be ignored",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
Name: "existing non image file - thumb parameter should be ignored",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
|
||||
if e.ThumbError == nil {
|
||||
t.Fatal("Expected thumb error, got nil")
|
||||
}
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
|
||||
@@ -25,6 +25,7 @@ func healthCheck(e *core.RequestEvent) error {
|
||||
Message: "API is healthy.",
|
||||
}
|
||||
|
||||
// @todo evaluate whether it is worth removing the extra info from the health endpoint
|
||||
if e.HasSuperuserAuth() {
|
||||
resp.Data = make(map[string]any, 3)
|
||||
resp.Data["canBackup"] = !e.App.Store().Has(core.StoreKeyActiveBackup)
|
||||
|
||||
+16
-2
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
// DefaultInstallerFunc is the default PocketBase installer function.
|
||||
@@ -22,17 +23,22 @@ import (
|
||||
//
|
||||
// See https://github.com/pocketbase/pocketbase/discussions/5814.
|
||||
func DefaultInstallerFunc(app core.App, systemSuperuser *core.Record, baseURL string) error {
|
||||
if ui.DistDirFS == nil {
|
||||
color.Magenta("You can create your first superuser by running: %s superuser upsert EMAIL PASS", executablePath())
|
||||
return nil
|
||||
}
|
||||
|
||||
token, err := systemSuperuser.NewStaticAuthToken(30 * time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// launch url (ignore errors and always print a help text as fallback)
|
||||
url := fmt.Sprintf("%s/_/#/pbinstal/%s", strings.TrimRight(baseURL, "/"), token)
|
||||
url := fmt.Sprintf("%s/_/#/pbinstall/%s", strings.TrimRight(baseURL, "/"), token)
|
||||
_ = osutils.LaunchURL(url)
|
||||
color.Magenta("\n(!) Launch the URL below in the browser if it hasn't been open already to create your first superuser account:")
|
||||
color.New(color.Bold).Add(color.FgCyan).Println(url)
|
||||
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", os.Args[0])
|
||||
color.New(color.FgHiBlack, color.Italic).Printf("(you can also create your first superuser by running: %s superuser upsert EMAIL PASS)\n\n", executablePath())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -86,3 +92,11 @@ func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func executablePath() string {
|
||||
if osutils.IsProbablyGoRun() {
|
||||
return "go run ."
|
||||
}
|
||||
|
||||
return os.Args[0]
|
||||
}
|
||||
|
||||
+23
-7
@@ -207,11 +207,13 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
||||
|
||||
func getAuthTokenFromRequest(e *core.RequestEvent) string {
|
||||
token := e.Request.Header.Get("Authorization")
|
||||
if token != "" {
|
||||
// the schema prefix is not required and it is only for
|
||||
// compatibility with the defaults of some HTTP clients
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
|
||||
// the "Bearer" schema prefix is not required by PocketBase and it is
|
||||
// supported only for compatibility with the defaults of some HTTP clients
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
return token[7:]
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -365,11 +367,25 @@ func logRequest(event *core.RequestEvent, err error) {
|
||||
|
||||
// parse the request error
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*router.ApiError); ok {
|
||||
status = apiErr.Status
|
||||
apiErr, isPlainApiError := err.(*router.ApiError)
|
||||
if isPlainApiError || errors.As(err, &apiErr) {
|
||||
// the status header wasn't written yet
|
||||
if status == 0 {
|
||||
status = apiErr.Status
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if isPlainApiError {
|
||||
errMsg = apiErr.Message
|
||||
} else {
|
||||
// wrapped ApiError -> add the full serialized version
|
||||
// of the original error since it could contain more information
|
||||
errMsg = err.Error()
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("error", apiErr.Message),
|
||||
slog.String("error", errMsg),
|
||||
slog.Any("details", apiErr.RawData()),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
var ErrRequestEntityTooLarge = router.NewApiError(http.StatusRequestEntityTooLarge, "Request entity too large", nil)
|
||||
|
||||
const DefaultMaxBodySize int64 = 32 << 20
|
||||
const DefaultMaxBodySize int64 = 32 << 20 // @todo consider replacing with router.DefaultMaxMemory
|
||||
|
||||
const (
|
||||
DefaultBodyLimitMiddlewareId = "pbBodyLimit"
|
||||
@@ -112,9 +112,21 @@ func (r *limitedReader) Read(b []byte) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// explicit casts to ensure that the main struct methods will be invoked
|
||||
// (extra precautions in case of nested interface wrapping erasure)
|
||||
// ---
|
||||
|
||||
func (r *limitedReader) Reread() {
|
||||
rr, ok := r.ReadCloser.(router.Rereader)
|
||||
rereader, ok := r.ReadCloser.(router.Rereader)
|
||||
if ok {
|
||||
rr.Reread()
|
||||
rereader.Reread()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *limitedReader) Close() error {
|
||||
closer, ok := r.ReadCloser.(io.Closer)
|
||||
if ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -107,28 +108,6 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
//
|
||||
//nolint:unused
|
||||
func isClientRateLimited(e *core.RequestEvent, rtId string) bool {
|
||||
rateLimiters, ok := e.App.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok || rateLimiters == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rt, ok := rateLimiters.GetOk(rtId)
|
||||
if !ok || rt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client, ok := rt.getClient(e.RealIP())
|
||||
if !ok || client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return client.available <= 0 && time.Now().Unix()-client.lastConsume < client.interval
|
||||
}
|
||||
|
||||
// @todo consider exporting as helper?
|
||||
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
|
||||
switch rule.Audience {
|
||||
@@ -153,7 +132,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
}
|
||||
|
||||
rt := rateLimiters.GetOrSet(rtId, func() *rateLimiter {
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, rule.Duration+1800)
|
||||
return newRateLimiter(rule.MaxRequests, rule.Duration, 1800)
|
||||
})
|
||||
if rt == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiter", "id", rtId)
|
||||
@@ -167,7 +146,7 @@ func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule)
|
||||
}
|
||||
|
||||
if !rt.isAllowed(key) {
|
||||
return e.TooManyRequestsError("", nil)
|
||||
return e.TooManyRequestsError("", errors.New("triggered rate limit rule: "+rule.String()))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -233,12 +212,12 @@ func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec
|
||||
maxAllowed: maxAllowed,
|
||||
interval: intervalInSec,
|
||||
minDeleteInterval: minDeleteIntervalInSec,
|
||||
clients: map[string]*fixedWindow{},
|
||||
clients: map[string]*rateClient{},
|
||||
}
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
clients map[string]*fixedWindow
|
||||
clients map[string]*rateClient
|
||||
|
||||
maxAllowed int
|
||||
interval int64
|
||||
@@ -249,7 +228,7 @@ type rateLimiter struct {
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (rt *rateLimiter) getClient(key string) (*fixedWindow, bool) {
|
||||
func (rt *rateLimiter) getClient(key string) (*rateClient, bool) {
|
||||
rt.RLock()
|
||||
client, ok := rt.clients[key]
|
||||
rt.RUnlock()
|
||||
@@ -268,7 +247,7 @@ func (rt *rateLimiter) isAllowed(key string) bool {
|
||||
// check again in case the client was added by another request
|
||||
client, ok = rt.clients[key]
|
||||
if !ok {
|
||||
client = newFixedWindow(rt.maxAllowed, rt.interval)
|
||||
client = newRateClient(rt.maxAllowed, rt.interval)
|
||||
rt.clients[key] = client
|
||||
}
|
||||
rt.Unlock()
|
||||
@@ -294,7 +273,7 @@ func (rt *rateLimiter) clean() {
|
||||
//
|
||||
// @todo remove after https://github.com/golang/go/issues/20135
|
||||
if rt.totalDeleted >= 300 {
|
||||
shrunk := make(map[string]*fixedWindow, len(rt.clients))
|
||||
shrunk := make(map[string]*rateClient, len(rt.clients))
|
||||
for k, v := range rt.clients {
|
||||
shrunk[k] = v
|
||||
}
|
||||
@@ -303,52 +282,54 @@ func (rt *rateLimiter) clean() {
|
||||
}
|
||||
}
|
||||
|
||||
func newFixedWindow(maxAllowed int, intervalInSec int64) *fixedWindow {
|
||||
return &fixedWindow{
|
||||
func newRateClient(maxAllowed int, intervalInSec int64) *rateClient {
|
||||
return &rateClient{
|
||||
maxAllowed: maxAllowed,
|
||||
interval: intervalInSec,
|
||||
}
|
||||
}
|
||||
|
||||
type fixedWindow struct {
|
||||
// @todo evaluate swiching to sliding window with approximation counter similar to Cloudflare.
|
||||
//
|
||||
// rateClient implements fixed window rate limit strategy.
|
||||
type rateClient struct {
|
||||
// use plain Mutex instead of RWMutex since the operations are expected
|
||||
// to be mostly writes (e.g. consume()) and it should perform better
|
||||
sync.Mutex
|
||||
|
||||
maxAllowed int // the max allowed tokens per interval
|
||||
available int // the total available tokens
|
||||
interval int64 // in seconds
|
||||
lastConsume int64 // the time of the last consume
|
||||
maxAllowed int // the max allowed tokens per interval
|
||||
available int // the total available tokens
|
||||
start int64 // the start time of the current window
|
||||
interval int64 // in seconds
|
||||
}
|
||||
|
||||
// hasExpired checks whether it has been at least minElapsed seconds since the lastConsume time.
|
||||
// hasExpired checks whether it has been at least minElapsed seconds after the last active window.
|
||||
// (usually used to perform periodic cleanup of staled instances).
|
||||
func (l *fixedWindow) hasExpired(relativeNow int64, minElapsed int64) bool {
|
||||
func (l *rateClient) hasExpired(relativeNow int64, minElapsed int64) bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
return relativeNow-l.lastConsume > minElapsed
|
||||
return relativeNow-(l.start+l.interval) > minElapsed
|
||||
}
|
||||
|
||||
// consume decrease the current window allowance with 1 (if not exhausted already).
|
||||
// consume decreases the current allowance with 1 (if not exhausted already).
|
||||
//
|
||||
// It returns false if the allowance has been already exhausted and the user
|
||||
// has to wait until it resets back to its maxAllowed value.
|
||||
func (l *fixedWindow) consume() bool {
|
||||
func (l *rateClient) consume() bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
|
||||
// reset consumed counter
|
||||
if nowUnix-l.lastConsume >= l.interval {
|
||||
// reset
|
||||
if nowUnix-l.start >= l.interval {
|
||||
l.available = l.maxAllowed
|
||||
l.start = nowUnix
|
||||
}
|
||||
|
||||
if l.available > 0 {
|
||||
l.available--
|
||||
l.lastConsume = nowUnix
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
wait float64
|
||||
wait float64 // ms
|
||||
authenticated bool
|
||||
expectedStatus int
|
||||
}{
|
||||
@@ -85,10 +85,13 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/norate", 0, false, 200},
|
||||
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 700, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 800, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 1.1, false, 200},
|
||||
{"/rate/a", 1000, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
{"/rate/b", 1.1, false, 200},
|
||||
{"/rate/b", 1000, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
@@ -118,7 +121,7 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
{"/rate/guest", 0, false, 429},
|
||||
|
||||
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||
{"/rate/guest", 1, true, 200},
|
||||
{"/rate/guest", 1000, true, 200},
|
||||
{"/rate/guest", 0, true, 200},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
@@ -126,10 +129,6 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.url, func(t *testing.T) {
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Second)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", s.url, nil)
|
||||
|
||||
@@ -147,6 +146,10 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
@@ -224,6 +224,22 @@ func TestRequireAuth(t *testing.T) {
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "valid record auth token with Bearer case-insensitive prefix",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
// regular user
|
||||
"Authorization": "BeArEr eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireAuth())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+17
-13
@@ -84,11 +84,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
Data: []byte(`{"clientId":"` + ce.Client.Id() + `"}`),
|
||||
}
|
||||
connectMsgErr := ce.App.OnRealtimeMessageSend().Trigger(connectMsgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
@@ -123,11 +122,10 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
msgEvent.Client = ce.Client
|
||||
msgEvent.Message = &msg
|
||||
msgErr := ce.App.OnRealtimeMessageSend().Trigger(msgEvent, func(me *core.RealtimeMessageEvent) error {
|
||||
me.Response.Write([]byte("id:" + me.Client.Id() + "\n"))
|
||||
me.Response.Write([]byte("event:" + me.Message.Name + "\n"))
|
||||
me.Response.Write([]byte("data:"))
|
||||
me.Response.Write(me.Message.Data)
|
||||
me.Response.Write([]byte("\n\n"))
|
||||
err := me.Message.WriteSSE(me.Response, me.Client.Id())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return me.Flush()
|
||||
})
|
||||
if msgErr != nil {
|
||||
@@ -215,7 +213,9 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ func realtimeCanAccessRecord(
|
||||
|
||||
var exists int
|
||||
|
||||
q := app.DB().Select("(1)").
|
||||
q := app.ConcurrentDB().Select("(1)").
|
||||
From(record.Collection().Name).
|
||||
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
|
||||
|
||||
@@ -769,7 +769,11 @@ func realtimeCanAccessRecord(
|
||||
}
|
||||
|
||||
q.AndWhere(expr)
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = q.Limit(1).Row(&exists)
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to confirm email change.", err))
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -136,7 +135,7 @@ func TestRecordConfirmEmailChange(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
|
||||
Name: "OnRecordConfirmEmailChangeRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -145,15 +144,22 @@ func TestRecordConfirmEmailChange(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmEmailChangeRequest().BindFunc(func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmEmailChangeRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -43,7 +43,9 @@ func recordRequestEmailChange(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to request email change.", err))
|
||||
}
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,33 @@ func TestRecordRequestEmailChange(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestEmailChangeRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestEmailChangeRequest().BindFunc(func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestEmailChangeRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -26,15 +26,15 @@ func recordAuthImpersonate(e *core.RequestEvent) error {
|
||||
|
||||
form := &impersonateForm{}
|
||||
if err = e.BindBody(form); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
return e.BadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
if err = form.validate(); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
return e.BadRequestError("An error occurred while validating the submitted data.", err)
|
||||
}
|
||||
|
||||
token, err := record.NewStaticAuthToken(time.Duration(form.Duration) * time.Second)
|
||||
if err != nil {
|
||||
e.InternalServerError("Failed to generate static auth token", err)
|
||||
return e.InternalServerError("Failed to generate static auth token", err)
|
||||
}
|
||||
|
||||
return recordAuthResponse(e, record, token, "", nil)
|
||||
|
||||
@@ -34,6 +34,7 @@ type oauth2Response struct {
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Logo string `json:"logo"`
|
||||
State string `json:"state"`
|
||||
AuthURL string `json:"authURL"`
|
||||
|
||||
@@ -68,7 +69,14 @@ func (amr *authMethodsResponse) fillLegacyFields() {
|
||||
amr.UsernamePassword = amr.Password.Enabled && slices.Contains(amr.Password.IdentityFields, "username")
|
||||
|
||||
if amr.OAuth2.Enabled {
|
||||
amr.AuthProviders = amr.OAuth2.Providers
|
||||
// clone without the logo
|
||||
legacyProviders := make([]providerInfo, len(amr.OAuth2.Providers))
|
||||
for i, p := range amr.OAuth2.Providers {
|
||||
legacyProviders[i] = p
|
||||
legacyProviders[i].Logo = ""
|
||||
}
|
||||
|
||||
amr.AuthProviders = legacyProviders
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +136,7 @@ func recordAuthMethods(e *core.RequestEvent) error {
|
||||
info := providerInfo{
|
||||
Name: config.Name,
|
||||
DisplayName: provider.DisplayName(),
|
||||
Logo: provider.Logo(),
|
||||
State: security.RandomString(30),
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
`"providers":[{`,
|
||||
`"name":"google"`,
|
||||
`"name":"gitlab"`,
|
||||
`"logo":"\u003csvg`,
|
||||
`"logo":""`, // for the legacy fields
|
||||
`"state":`,
|
||||
`"displayName":`,
|
||||
`"codeVerifier":`,
|
||||
|
||||
@@ -108,8 +108,8 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": otp.Id,
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"otpId": otp.Id})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -247,6 +247,31 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestOTPRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-otp",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
Delay: 100 * time.Millisecond,
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestOTPRequest().BindFunc(func(e *core.RecordCreateOTPRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestOTPRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -54,7 +54,9 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
|
||||
|
||||
e.App.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -282,7 +281,7 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
|
||||
Name: "OnRecordConfirmPasswordResetRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -292,15 +291,22 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmPasswordResetRequest().BindFunc(func(e *core.RecordConfirmPasswordResetRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmPasswordResetRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -65,7 +65,9 @@ func recordRequestPasswordReset(e *core.RequestEvent) error {
|
||||
})
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,30 @@ func TestRecordRequestPasswordReset(t *testing.T) {
|
||||
app.Store().Set(resendKey, struct{}{})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestPasswordResetRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-password-reset",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestPasswordResetRequest().BindFunc(func(e *core.RecordRequestPasswordResetRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestPasswordResetRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -12,18 +12,24 @@ func recordAuthRefresh(e *core.RequestEvent) error {
|
||||
return e.NotFoundError("Missing auth record context.", nil)
|
||||
}
|
||||
|
||||
currentToken := getAuthTokenFromRequest(e)
|
||||
claims, _ := security.ParseUnverifiedJWT(currentToken)
|
||||
if v, ok := claims[core.TokenClaimRefreshable]; !ok || !cast.ToBool(v) {
|
||||
return e.ForbiddenError("The current auth token is not refreshable.", nil)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthRefreshRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
return e.App.OnRecordAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshRequestEvent) error {
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, "", nil)
|
||||
token := getAuthTokenFromRequest(e.RequestEvent)
|
||||
|
||||
// skip token renewal if the token's payload doesn't explicitly allow it (e.g. impersonate tokens)
|
||||
claims, _ := security.ParseUnverifiedJWT(token) //
|
||||
if v, ok := claims[core.TokenClaimRefreshable]; ok && cast.ToBool(v) {
|
||||
var tokenErr error
|
||||
token, tokenErr = e.Record.NewAuthToken()
|
||||
if tokenErr != nil {
|
||||
return e.InternalServerError("Failed to refresh auth token.", tokenErr)
|
||||
}
|
||||
}
|
||||
|
||||
return recordAuthResponse(e.RequestEvent, e.Record, token, "", nil)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -74,6 +73,8 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"missing":`,
|
||||
// should return a different token
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -89,9 +90,21 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
// should return the same token
|
||||
`"token":"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6ZmFsc2V9.4IsO6YMsR19crhwl_YWzvRH8pfq2Ri4Gv2dzGyneLak"`,
|
||||
`"record":`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"emailVisibility":false`,
|
||||
`"email":"test@example.com"`, // the owner can always view their email address
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthRefreshRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unverified auth record in onlyVerified collection",
|
||||
@@ -130,23 +143,30 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Name: "OnRecordAuthRefreshRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-refresh?expand=rel,missing",
|
||||
URL: "/api/collections/users/auth-refresh",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordAuthRefreshRequest().BindFunc(func(e *core.RecordAuthRefreshRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthRefreshRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthRefreshRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -42,19 +42,19 @@ func recordConfirmVerification(e *core.RequestEvent) error {
|
||||
event.Record = record
|
||||
|
||||
return e.App.OnRecordConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationRequestEvent) error {
|
||||
if wasVerified {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
if !wasVerified {
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
}
|
||||
}
|
||||
|
||||
e.App.Store().Remove(getVerificationResendKey(e.Record))
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -144,7 +143,7 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmVerificationRequest error response",
|
||||
Name: "OnRecordConfirmVerificationRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -152,15 +151,22 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordConfirmVerificationRequest().BindFunc(func(e *core.RecordConfirmVerificationRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmVerificationRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordConfirmVerificationRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
|
||||
@@ -68,7 +68,9 @@ func recordRequestVerification(e *core.RequestEvent) error {
|
||||
})
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,30 @@ func TestRecordRequestVerification(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordRequestVerificationRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/request-verification",
|
||||
Body: strings.NewReader(`{"email":"test@example.com"}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordRequestVerificationRequest().BindFunc(func(e *core.RecordRequestVerificationRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordRequestVerificationRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
+146
-16
@@ -1,14 +1,20 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -17,6 +23,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -35,6 +42,8 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
fallbackAuthRecord = e.Auth
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)
|
||||
|
||||
form := new(recordOAuth2LoginForm)
|
||||
form.collection = collection
|
||||
if err = e.BindBody(form); err != nil {
|
||||
@@ -88,6 +97,19 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
|
||||
}
|
||||
|
||||
// Apple currently returns the user's name only as part of the first redirect data response
|
||||
// so we try to assign the [apis.oauth2SubscriptionRedirect] forwarded name.
|
||||
if form.Provider == auth.NameApple && authUser.Name == "" {
|
||||
nameKey := oauth2RedirectAppleNameStoreKeyPrefix + form.Code
|
||||
name, ok := e.App.Store().Get(nameKey).(string)
|
||||
if ok {
|
||||
e.App.Store().Remove(nameKey)
|
||||
authUser.Name = name
|
||||
} else {
|
||||
e.App.Logger().Debug("Missing or already removed Apple user's name")
|
||||
}
|
||||
}
|
||||
|
||||
var authRecord *core.Record
|
||||
|
||||
// check for existing relation with the auth collection
|
||||
@@ -134,13 +156,17 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
|
||||
}
|
||||
|
||||
meta := struct {
|
||||
*auth.AuthUser
|
||||
IsNew bool `json:"isNew"`
|
||||
}{
|
||||
AuthUser: e.OAuth2User,
|
||||
IsNew: e.IsNewRecord,
|
||||
// @todo revert back to struct after removing the custom auth.AuthUser marshalization
|
||||
meta := map[string]any{}
|
||||
rawOAuth2User, err := json.Marshal(e.OAuth2User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal(rawOAuth2User, &meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta["isNew"] = e.IsNewRecord
|
||||
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
|
||||
})
|
||||
@@ -193,16 +219,31 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @todo evaluate if it is still worth keeping as this exists only for backward-compatibility with pre v0.23 versions
|
||||
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
|
||||
field := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username)
|
||||
if field == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// ensure that the value matches the pattern of the username field (if text)
|
||||
if txtField, ok := field.(*core.TextField); ok && txtField.ValidatePlainValue(username) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// ensure that username is unique
|
||||
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, collection.OAuth2.MappedFields.Username)
|
||||
index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field.GetName())
|
||||
if hasUniqueue {
|
||||
// it is not required because collection fields are already sanitized
|
||||
// but normalize as an extra precaution in case of a custom validator
|
||||
colName := inflector.Columnify(field.GetName())
|
||||
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("username = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
expr = dbx.NewExp("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{"username": username}
|
||||
expr = dbx.HashExp{colName: username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
@@ -212,10 +253,7 @@ func oldCanAssignUsername(txApp core.App, collection *core.Collection, username
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that the value matches the pattern of the username field (if text)
|
||||
txtField, _ := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username).(*core.TextField)
|
||||
|
||||
return txtField != nil && txtField.ValidatePlainValue(username) == nil
|
||||
return true
|
||||
}
|
||||
|
||||
func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
|
||||
@@ -232,7 +270,11 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload = map[string]any{}
|
||||
}
|
||||
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
// assign the OAuth2 user email only if the user hasn't submitted one
|
||||
// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
|
||||
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
}
|
||||
|
||||
// map known fields (unless the field was explicitly submitted as part of CreateData)
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
|
||||
@@ -257,9 +299,12 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
|
||||
// download the avatar if the mapped field is a file
|
||||
avatarFile, err := func() (*filesystem.File, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return filesystem.NewFileFromURL(ctx, e.OAuth2User.AvatarURL)
|
||||
|
||||
// the extra checks are not required because the OAuth2 APIs are trusted vendor
|
||||
// but are here to minimize the impact in case the provider is vulnerable
|
||||
return safeFileFromURL(ctx, e.OAuth2User.AvatarURL)
|
||||
}()
|
||||
if err != nil {
|
||||
txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
|
||||
@@ -362,3 +407,88 @@ func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2R
|
||||
|
||||
return createdRecord, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// safeHTTPClient initializes a custom http.Client with extra host checks
|
||||
// to prevent internal network probing requests
|
||||
// (aka. disallow loopback, private, multicast, etc. requests).
|
||||
//
|
||||
// NB! The host checks are not perfect and there are probably edge cases that are not covered,
|
||||
// so if you plan using with untrusted user URL, consider performing additional whitelist checks.
|
||||
//
|
||||
// @todo Evaluate with the refactoring if worth exporting(+tests) and moving under the security package.
|
||||
func safeHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
// the same options as in http.DefaultTransport.DialContext
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
|
||||
// check the address right after estrablishing the connection to prevent dns rebinding
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
|
||||
if ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() {
|
||||
return fmt.Errorf("address %q is invalid or resolve to disallowed IP", address)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: 180 * time.Second, // can be still cancelled with the request context
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
// the same options as in http.DefaultTransport
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// safeFileFromURL downloads the file from the specified url (using safeHTTPClient)
|
||||
// and creates a new filesystem.File value from its content (limited to DefaultMaxBodySize).
|
||||
//
|
||||
// @todo Evaluate with the refactoring if worth exporting/replacing filesystem.NewFileFromURL (or redefine as NewUnsafeFileFromURL).
|
||||
func safeFileFromURL(ctx context.Context, url string) (*filesystem.File, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := safeHTTPClient()
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode > 399 {
|
||||
return nil, fmt.Errorf("failed to download url %s (%d)", url, res.StatusCode)
|
||||
}
|
||||
|
||||
body := io.LimitReader(res.Body, DefaultMaxBodySize)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err = io.Copy(&buf, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filesystem.NewFileFromBytes(buf.Bytes(), path.Base(url))
|
||||
}
|
||||
|
||||
@@ -2,36 +2,39 @@ package apis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
)
|
||||
|
||||
const (
|
||||
oauth2SubscriptionTopic string = "@oauth2"
|
||||
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
|
||||
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
|
||||
oauth2SubscriptionTopic string = "@oauth2"
|
||||
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
|
||||
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
|
||||
oauth2RedirectAppleNameStoreKeyPrefix string = "@redirect_name_"
|
||||
)
|
||||
|
||||
type oauth2RedirectData struct {
|
||||
State string `form:"state" json:"state"`
|
||||
Code string `form:"code" json:"code"`
|
||||
Error string `form:"error" json:"error,omitempty"`
|
||||
|
||||
// returned by Apple only
|
||||
AppleUser string `form:"user" json:"-"`
|
||||
}
|
||||
|
||||
func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
redirectStatusCode := http.StatusTemporaryRedirect
|
||||
if e.Request.Method != http.MethodGet {
|
||||
redirectStatusCode = http.StatusSeeOther
|
||||
}
|
||||
|
||||
data := oauth2RedirectData{}
|
||||
|
||||
if e.Request.Method == http.MethodPost {
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
e.App.Logger().Debug("Failed to read OAuth2 redirect data", "error", err)
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
} else {
|
||||
query := e.Request.URL.Query()
|
||||
@@ -42,20 +45,34 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
|
||||
if data.State == "" {
|
||||
e.App.Logger().Debug("Missing OAuth2 state parameter")
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
client, err := e.App.SubscriptionsBroker().ClientById(data.State)
|
||||
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
|
||||
e.App.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", data.State)
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
defer client.Unsubscribe(oauth2SubscriptionTopic)
|
||||
|
||||
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/7090)
|
||||
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
|
||||
nameErr := parseAndStoreAppleRedirectName(
|
||||
e.App,
|
||||
oauth2RedirectAppleNameStoreKeyPrefix+data.Code,
|
||||
data.AppleUser,
|
||||
)
|
||||
if nameErr != nil {
|
||||
// non-critical error
|
||||
e.App.Logger().Debug("Failed to parse and load Apple Redirect name data", "error", nameErr)
|
||||
}
|
||||
}
|
||||
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
e.App.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
@@ -67,8 +84,87 @@ func oauth2SubscriptionRedirect(e *core.RequestEvent) error {
|
||||
|
||||
if data.Error != "" || data.Code == "" {
|
||||
e.App.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectFailurePath)
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode, oauth2RedirectSuccessPath)
|
||||
return successRedirect(e)
|
||||
}
|
||||
|
||||
func redirectStatusCode(e *core.RequestEvent) int {
|
||||
if e.Request.Method != http.MethodGet {
|
||||
return http.StatusSeeOther
|
||||
}
|
||||
|
||||
return http.StatusTemporaryRedirect
|
||||
}
|
||||
|
||||
func failureRedirect(e *core.RequestEvent) error {
|
||||
// fallback if UI is not bundled
|
||||
if ui.DistDirFS == nil {
|
||||
return e.String(http.StatusOK, "Failed to authenticate. You can close this window and go back to the app to try again.")
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode(e), oauth2RedirectFailurePath)
|
||||
}
|
||||
|
||||
func successRedirect(e *core.RequestEvent) error {
|
||||
// fallback if UI is not bundled
|
||||
if ui.DistDirFS == nil {
|
||||
return e.HTML(http.StatusOK, "Auth completed. You can close this window and go back to the app.")
|
||||
}
|
||||
|
||||
return e.Redirect(redirectStatusCode(e), oauth2RedirectSuccessPath)
|
||||
}
|
||||
|
||||
// parseAndStoreAppleRedirectName extracts the first and last name
|
||||
// from serializedNameData and temporary store them in the app.Store.
|
||||
//
|
||||
// This is hacky workaround to forward safely and seamlessly the Apple
|
||||
// redirect user's name back to the OAuth2 auth handler.
|
||||
//
|
||||
// Note that currently Apple is the only provider that behaves like this and
|
||||
// for now it is unnecessary to check whether the redirect is coming from Apple or not.
|
||||
//
|
||||
// Ideally this shouldn't be needed and will be removed in the future
|
||||
// once Apple adds a dedicated userinfo endpoint.
|
||||
func parseAndStoreAppleRedirectName(app core.App, nameKey string, serializedNameData string) error {
|
||||
if serializedNameData == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// just in case to prevent storing large strings in memory
|
||||
if len(nameKey) > 1000 {
|
||||
return errors.New("nameKey is too large")
|
||||
}
|
||||
|
||||
// https://developer.apple.com/documentation/signinwithapple/incorporating-sign-in-with-apple-into-other-platforms#Handle-the-response
|
||||
extracted := struct {
|
||||
Name struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
} `json:"name"`
|
||||
}{}
|
||||
if err := json.Unmarshal([]byte(serializedNameData), &extracted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullName := extracted.Name.FirstName + " " + extracted.Name.LastName
|
||||
|
||||
// truncate just in case to prevent storing large strings in memory
|
||||
if len(fullName) > 150 {
|
||||
fullName = fullName[:150]
|
||||
}
|
||||
|
||||
fullName = strings.TrimSpace(fullName)
|
||||
if fullName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// store (and remove)
|
||||
app.Store().Set(nameKey, fullName)
|
||||
time.AfterFunc(1*time.Minute, func() {
|
||||
app.Store().Remove(nameKey)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package apis_test
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -266,6 +267,74 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "(POST) Apple user's name json (nameKey error)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/oauth2-redirect",
|
||||
Body: strings.NewReader(url.Values{
|
||||
"code": []string{strings.Repeat("a", 986)},
|
||||
"state": []string{clientStubs[8]["c3"].Id()},
|
||||
"user": []string{
|
||||
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
|
||||
},
|
||||
}.Encode()),
|
||||
Headers: map[string]string{
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[8], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[8]["c3"].Id(), `"code":"` + strings.Repeat("a", 986) + `"`},
|
||||
}),
|
||||
ExpectedStatus: http.StatusSeeOther,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkSuccessRedirect(t, app, res)
|
||||
|
||||
if clientStubs[8]["c3"].HasSubscription("@oauth2") {
|
||||
t.Fatalf("Expected oauth2 subscription to be removed")
|
||||
}
|
||||
|
||||
if storedName := app.Store().Get("@redirect_name_" + strings.Repeat("a", 986)); storedName != nil {
|
||||
t.Fatalf("Didn't expect stored name, got %q", storedName)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "(POST) Apple user's name json",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/oauth2-redirect",
|
||||
Body: strings.NewReader(url.Values{
|
||||
"code": []string{strings.Repeat("a", 985)},
|
||||
"state": []string{clientStubs[9]["c3"].Id()},
|
||||
"user": []string{
|
||||
`{"name":{"firstName":"aaa","lastName":"` + strings.Repeat("b", 200) + `"}}`,
|
||||
},
|
||||
}.Encode()),
|
||||
Headers: map[string]string{
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[9], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[9]["c3"].Id(), `"code":"` + strings.Repeat("a", 985) + `"`},
|
||||
}),
|
||||
ExpectedStatus: http.StatusSeeOther,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkSuccessRedirect(t, app, res)
|
||||
|
||||
if clientStubs[9]["c3"].HasSubscription("@oauth2") {
|
||||
t.Fatalf("Expected oauth2 subscription to be removed")
|
||||
}
|
||||
|
||||
storedName, _ := app.Store().Get("@redirect_name_" + strings.Repeat("a", 985)).(string)
|
||||
expectedName := "aaa " + strings.Repeat("b", 146)
|
||||
if storedName != expectedName {
|
||||
t.Fatalf("Expected stored name\n%q\ngot\n%q", expectedName, storedName)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -45,12 +45,14 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// start a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
localServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
|
||||
buf := new(bytes.Buffer)
|
||||
png.Encode(buf, image.Rect(0, 0, 1, 1)) // tiny 1x1 png
|
||||
http.ServeContent(res, req, "test_avatar.png", time.Now(), bytes.NewReader(buf.Bytes()))
|
||||
}))
|
||||
defer server.Close()
|
||||
defer localServer.Close()
|
||||
|
||||
externalImageURL := "https://pocketbase.io/images/logo.svg"
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -175,6 +177,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOAuth2 {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOAuth2, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -293,6 +309,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"id":"oap640cot4yru2s"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -384,6 +401,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -489,6 +507,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -602,6 +621,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test_oauth2@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -707,6 +727,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -796,6 +817,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"id":"test_id"`,
|
||||
`"verified":true`,
|
||||
@@ -877,7 +899,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"verified":{"code":"validation_values_mismatch"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
`"email":`, // ignored because the record validator never ran
|
||||
`"rel":`, // ignored because the record validator never ran
|
||||
`"file":`, // ignored because the record validator never ran
|
||||
},
|
||||
@@ -930,12 +952,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"email":{"code":"validation_is_email"`,
|
||||
`"rel":{"code":"validation_missing_rel_records"`,
|
||||
`"file":{"code":"validation_invalid_file"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"email":`, // the value is always overwritten with the OAuth2 user email
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
@@ -949,7 +969,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data)",
|
||||
Name: "creating user (valid create data with empty submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -957,7 +977,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "invalid",
|
||||
"email": "",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
@@ -994,6 +1014,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1041,6 +1062,95 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (valid create data with non-empty valid submitted email)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "test_create@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
}
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
usersCol, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "test_id",
|
||||
Email: "oauth2@example.com", // should be ignored because of the explicit submitted email
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(usersCol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test_create@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"test_username"`,
|
||||
`"verified":false`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 2, // the auth response and from the create request
|
||||
// ---
|
||||
"OnModelCreate": 3, // record + authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 3,
|
||||
"OnModelAfterCreateSuccess": 3,
|
||||
"OnRecordCreate": 3,
|
||||
"OnRecordCreateExecute": 3,
|
||||
"OnRecordAfterCreateSuccess": 3,
|
||||
// ---
|
||||
"OnModelValidate": 3,
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindFirstRecordByData("users", "username", "test_username")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->file field)",
|
||||
Method: http.MethodPost,
|
||||
@@ -1068,7 +1178,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
AvatarURL: externalImageURL,
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
@@ -1093,13 +1203,105 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"verified":true`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
`"avatar":"oauth2_avatar_`,
|
||||
`"avatar":"logo_`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 2, // the auth response and from the create request
|
||||
// ---
|
||||
"OnModelCreate": 3, // record + authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 3,
|
||||
"OnModelAfterCreateSuccess": 3,
|
||||
"OnRecordCreate": 3,
|
||||
"OnRecordCreateExecute": 3,
|
||||
"OnRecordAfterCreateSuccess": 3,
|
||||
// ---
|
||||
"OnModelUpdate": 1, // created record verified state change
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelValidate": 4,
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and local avatarURL->file field; ensures that safeHTTPClient is being used)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"name": "test_name",
|
||||
"emailVisibility": true,
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
}
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
usersCol, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
AvatarURL: localServer.URL + "/oauth2_avatar.png", // local/private file download is not allowed
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
usersCol.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
usersCol.OAuth2.MappedFields = core.OAuth2KnownFields{
|
||||
Username: "name", // should be ignored because of the explicit submitted value
|
||||
Id: "username",
|
||||
AvatarURL: "avatar",
|
||||
}
|
||||
if err := app.Save(usersCol); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"verified":true`,
|
||||
`"rel":"0yxhwia2amd8gec"`,
|
||||
`"avatar":"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
@@ -1175,6 +1377,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1233,7 +1436,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
Email: "oauth2@example.com",
|
||||
Username: "tESt2_username", // wouldn't match with existing because the related field index is case-sensitive
|
||||
Name: "oauth2_name",
|
||||
AvatarURL: server.URL + "/oauth2_avatar.png",
|
||||
AvatarURL: localServer.URL + "/oauth2_avatar.png", // allowed because it is not being downloaded
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
@@ -1257,6 +1460,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"username":"tESt2_username"`,
|
||||
@@ -1347,6 +1551,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1428,6 +1633,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1464,6 +1670,167 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithOAuth2Request tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{Id: "test_id"},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
user.Collection().MFA.Enabled = false
|
||||
user.Collection().OAuth2.Enabled = true
|
||||
user.Collection().OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// stub linked provider
|
||||
ea := core.NewExternalAuth(app)
|
||||
ea.SetCollectionRef(user.Collection().Id)
|
||||
ea.SetRecordRef(user.Id)
|
||||
ea.SetProvider("test")
|
||||
ea.SetProviderId("test_id")
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnRecordAuthWithOAuth2Request().BindFunc(func(e *core.RecordAuthWithOAuth2RequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithOAuth2Request": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// Apple AuthUser.Name assign checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "store name with Apple provider",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "apple",
|
||||
"code":"test_code",
|
||||
"redirectURL": "https://example.com"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers[auth.NameApple] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{Id: "test_id"},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
app.Store().Set("@redirect_name_test_code", "test_store_name")
|
||||
|
||||
// add the test provider in the collection
|
||||
users.MFA.Enabled = false
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: auth.NameApple,
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"meta":{`,
|
||||
`"name":"test_store_name"`,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.Store().Has("@redirect_name_test_code") {
|
||||
t.Fatal("Expected @redirect_name_test_code store key to be removed")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "store name with non-Apple provider",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
"provider": "test",
|
||||
"code":"test_code",
|
||||
"redirectURL": "https://example.com"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{Id: "test_id"},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
app.Store().Set("@redirect_name_test_code", "test_store_name")
|
||||
|
||||
// add the test provider in the collection
|
||||
users.MFA.Enabled = false
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: "test",
|
||||
ClientId: "123",
|
||||
ClientSecret: "456",
|
||||
}}
|
||||
if err := app.Save(users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
NotExpectedContent: []string{
|
||||
`"name":"test_store_name"`,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if !app.Store().Has("@redirect_name_test_code") {
|
||||
t.Fatal("Expected @redirect_name_test_code store key to NOT be deleted")
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -26,6 +26,8 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOTP)
|
||||
|
||||
event := new(core.RecordAuthWithOTPRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Collection = collection
|
||||
@@ -51,7 +53,7 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("Invalid or expired OTP", fmt.Errorf("missing auth record: %w", err))
|
||||
}
|
||||
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumaration protection
|
||||
// since otps are usually simple digit numbers, enforce an extra rate limit rule as basic enumeration protection
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, core.RateLimitRule{MaxRequests: 5, Duration: 180})
|
||||
if err != nil {
|
||||
return e.TooManyRequestsError("Too many attempts, please try again later with a new OTP.", nil)
|
||||
|
||||
@@ -269,6 +269,20 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextOTP {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextOTP, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -405,6 +419,53 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithOTPRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
"otpId":"` + strings.Repeat("a", 15) + `",
|
||||
"password":"123456"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// disable MFA
|
||||
user.Collection().MFA.Enabled = false
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
otp.Id = strings.Repeat("a", 15)
|
||||
otp.SetCollectionRef(user.Collection().Id)
|
||||
otp.SetRecordRef(user.Id)
|
||||
otp.SetPassword("123456")
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.OnRecordAuthWithOTPRequest().BindFunc(func(e *core.RecordAuthWithOTPRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithOTPRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -32,29 +32,40 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextPasswordAuth)
|
||||
|
||||
var foundRecord *core.Record
|
||||
var foundErr error
|
||||
|
||||
if form.IdentityField != "" {
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, form.IdentityField, form.Identity)
|
||||
} else {
|
||||
// prioritize email lookup
|
||||
isEmail := is.EmailFormat.Validate(form.Identity) == nil
|
||||
if isEmail && list.ExistInSlice(core.FieldNameEmail, collection.PasswordAuth.IdentityFields) {
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, core.FieldNameEmail, form.Identity)
|
||||
identityFields := collection.PasswordAuth.IdentityFields
|
||||
|
||||
// @todo consider removing with the stable release or moving it in the collection save
|
||||
//
|
||||
// prioritize email lookup to minimize breaking changes with earlier versions
|
||||
if len(identityFields) > 1 && identityFields[0] != core.FieldNameEmail {
|
||||
identityFields = slices.Clone(identityFields)
|
||||
slices.SortStableFunc(identityFields, func(a, b string) int {
|
||||
if a == "email" {
|
||||
return -1
|
||||
}
|
||||
if b == "email" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// search by the other identity fields
|
||||
if !isEmail || foundErr != nil {
|
||||
for _, name := range collection.PasswordAuth.IdentityFields {
|
||||
if !isEmail && name == core.FieldNameEmail {
|
||||
continue // no need to search by the email field if it is not an email
|
||||
}
|
||||
for _, name := range identityFields {
|
||||
if name == core.FieldNameEmail && is.EmailFormat.Validate(form.Identity) != nil {
|
||||
continue // no need to query the database if we know that the submitted value is not an email
|
||||
}
|
||||
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -82,7 +81,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAuthWithPasswordRequest error response",
|
||||
Name: "OnRecordAuthWithPasswordRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -91,15 +90,22 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordAuthWithPasswordRequest().BindFunc(func(e *core.RecordAuthWithPasswordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordAuthWithPasswordRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "valid identity field and invalid password",
|
||||
@@ -126,6 +132,21 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// test at least once that the correct request info context is properly loaded
|
||||
app.OnRecordAuthRequest().BindFunc(func(e *core.RecordAuthRequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if info.Context != core.RequestInfoContextPasswordAuth {
|
||||
t.Fatalf("Expected request context %q, got %q", core.RequestInfoContextPasswordAuth, info.Context)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
@@ -191,6 +212,57 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
// https://github.com/pocketbase/pocketbase/issues/7256
|
||||
Name: "valid non-email identity field with a value that is a properly formatted email",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"username_as_email@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
record, err := app.FindAuthRecordByEmail("clients", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record.Set("username", "username_as_email@example.com")
|
||||
|
||||
err = app.SaveNoValidate(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"username_as_email@example.com"`,
|
||||
`"token":`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// authOrigin track
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnMailerSend": 1,
|
||||
"OnMailerRecordAuthAlertSend": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unknown explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
|
||||
+130
-92
@@ -28,9 +28,9 @@ func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent])
|
||||
subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId)
|
||||
subGroup.GET("", recordsList)
|
||||
subGroup.GET("/{id}", recordView)
|
||||
subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.DELETE("/{id}", recordDelete(nil))
|
||||
subGroup.POST("", recordCreate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.PATCH("/{id}", recordUpdate(true, nil)).Bind(dynamicCollectionBodyLimit(""))
|
||||
subGroup.DELETE("/{id}", recordDelete(true, nil))
|
||||
}
|
||||
|
||||
func recordsList(e *core.RequestEvent) error {
|
||||
@@ -79,6 +79,11 @@ func recordsList(e *core.RequestEvent) error {
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).Query(query)
|
||||
|
||||
// use rowid when available to minimize the need of a covering index with the "id" field
|
||||
if !collection.IsView() {
|
||||
searchProvider.CountCol("_rowid_")
|
||||
}
|
||||
|
||||
records := []*core.Record{}
|
||||
result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records)
|
||||
if err != nil {
|
||||
@@ -113,10 +118,12 @@ func recordsList(e *core.RequestEvent) error {
|
||||
len(e.Records) == 0 &&
|
||||
checkRateLimit(e.RequestEvent, "@pb_list_timing_check_"+collection.Id, listTimingRateLimitRule) != nil {
|
||||
e.App.Logger().Debug("Randomized throttle because of too many failed searches", "collectionId", collection.Id)
|
||||
randomizedThrottle(150)
|
||||
randomizedThrottle(500)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,12 +169,18 @@ func recordView(e *core.RequestEvent) error {
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -187,11 +200,13 @@ func recordView(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordCreate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -251,6 +266,76 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// check the request and record data against the create and manage rules
|
||||
if !hasSuperuserAuth && collection.CreateRule != nil {
|
||||
dummyRecord := record.Clone()
|
||||
|
||||
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
|
||||
|
||||
// set an id if it doesn't have already
|
||||
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
|
||||
if dummyRecord.Id == "" {
|
||||
dummyRecord.Id = "__temp_id__" + dummyRandomPart
|
||||
}
|
||||
|
||||
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
dummyRecord.SetVerified(false)
|
||||
|
||||
// export the dummy record data into db params
|
||||
dummyExport, err := dummyRecord.DBExport(e.App)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
|
||||
}
|
||||
|
||||
dummyParams := make(dbx.Params, len(dummyExport))
|
||||
selects := make([]string, 0, len(dummyExport))
|
||||
var param string
|
||||
for k, v := range dummyExport {
|
||||
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
|
||||
param = "__pb_create__" + k
|
||||
dummyParams[param] = v
|
||||
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
|
||||
}
|
||||
|
||||
// shallow clone the current collection
|
||||
dummyCollection := *collection
|
||||
dummyCollection.Id += dummyRandomPart
|
||||
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
|
||||
|
||||
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
|
||||
|
||||
// check non-empty create rule
|
||||
if *dummyCollection.CreateRule != "" {
|
||||
ruleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
|
||||
}
|
||||
ruleQuery.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(ruleQuery)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule update query failure: %w", err))
|
||||
}
|
||||
|
||||
var exists int
|
||||
err = ruleQuery.Limit(1).Row(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// check for manage rule access
|
||||
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
}
|
||||
|
||||
var isOptFinalizerCalled bool
|
||||
|
||||
event := new(core.RecordRequestEvent)
|
||||
@@ -262,73 +347,6 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
|
||||
// temporary save the record and check it against the create and manage rules
|
||||
if !hasSuperuserAuth && e.Collection.CreateRule != nil {
|
||||
dummyRecord := e.Record.Clone()
|
||||
|
||||
dummyRandomPart := "__pb_create__" + security.PseudorandomString(6)
|
||||
|
||||
// set an id if it doesn't have already
|
||||
// (the value doesn't matter; it is used only to minimize the breaking changes with earlier versions)
|
||||
if dummyRecord.Id == "" {
|
||||
dummyRecord.Id = "__temp_id__" + dummyRandomPart
|
||||
}
|
||||
|
||||
// unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
dummyRecord.SetVerified(false)
|
||||
|
||||
// export the dummy record data into db params
|
||||
dummyExport, err := dummyRecord.DBExport(e.App)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("dummy DBExport error: %w", err))
|
||||
}
|
||||
|
||||
dummyParams := make(dbx.Params, len(dummyExport))
|
||||
selects := make([]string, 0, len(dummyExport))
|
||||
var param string
|
||||
for k, v := range dummyExport {
|
||||
k = inflector.Columnify(k) // columnify is just as extra measure in case of custom fields
|
||||
param = "__pb_create__" + k
|
||||
dummyParams[param] = v
|
||||
selects = append(selects, "{:"+param+"} AS [["+k+"]]")
|
||||
}
|
||||
|
||||
// shallow clone the current collection
|
||||
dummyCollection := *e.Collection
|
||||
dummyCollection.Id += dummyRandomPart
|
||||
dummyCollection.Name += inflector.Columnify(dummyRandomPart)
|
||||
|
||||
withFrom := fmt.Sprintf("WITH {{%s}} as (SELECT %s)", dummyCollection.Name, strings.Join(selects, ","))
|
||||
|
||||
// check non-empty create rule
|
||||
if *dummyCollection.CreateRule != "" {
|
||||
ruleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, &dummyCollection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*dummyCollection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule build expression failure: %w", err))
|
||||
}
|
||||
ruleQuery.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(ruleQuery)
|
||||
|
||||
var exists int
|
||||
err = ruleQuery.Limit(1).Row(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
return e.BadRequestError("Failed to create record", fmt.Errorf("create rule failure: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// check for manage rule access
|
||||
manageRuleQuery := e.App.DB().Select("(1)").PreFragment(withFrom).From(dummyCollection.Name).AndBind(dummyParams)
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, &dummyCollection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record", err))
|
||||
@@ -339,7 +357,9 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
err = e.JSON(http.StatusOK, e.Record)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -369,7 +389,7 @@ func recordCreate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordUpdate(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -419,12 +439,18 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !hasSuperuserAuth && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -441,6 +467,14 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
form.Load(data)
|
||||
|
||||
manageRuleQuery := e.App.ConcurrentDB().Select("(1)").From(collection.Name).AndWhere(dbx.HashExp{
|
||||
collection.Name + ".id": record.Id,
|
||||
})
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, collection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
|
||||
var isOptFinalizerCalled bool
|
||||
|
||||
event := new(core.RecordRequestEvent)
|
||||
@@ -452,15 +486,6 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
|
||||
manageRuleQuery := e.App.DB().Select("(1)").From(e.Collection.Name).AndWhere(dbx.HashExp{
|
||||
// note: use the original record id and not e.Record.Id because the record validations because may get overwritten
|
||||
e.Collection.Name + ".id": e.Record.LastSavedPK(),
|
||||
})
|
||||
if !form.HasManageAccess() &&
|
||||
hasAuthManageAccess(e.App, requestInfo, e.Collection, manageRuleQuery) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to update record.", err))
|
||||
@@ -471,7 +496,9 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich record", err))
|
||||
}
|
||||
|
||||
err = e.JSON(http.StatusOK, e.Record)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -501,7 +528,7 @@ func recordUpdate(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
func recordDelete(responseWriteAfterTx bool, optFinalizer func(data any) error) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -534,12 +561,18 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if !requestInfo.HasSuperuserAuth() && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -561,7 +594,9 @@ func recordDelete(optFinalizer func(data any) error) func(e *core.RequestEvent)
|
||||
return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err))
|
||||
}
|
||||
|
||||
err = e.NoContent(http.StatusNoContent)
|
||||
err = execAfterSuccessTx(responseWriteAfterTx, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -718,7 +753,10 @@ func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(query)
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists int
|
||||
|
||||
|
||||
+136
-46
@@ -2,7 +2,6 @@ package apis_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -383,10 +382,32 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of",
|
||||
Name: "multi-match - at least one of (guest - non-satisfied relation filter API rule)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":0`,
|
||||
`"totalItems":0`,
|
||||
`"items":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - at least one of (clients)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length?=2"),
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
@@ -402,9 +423,13 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi-match - all",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
Name: "multi-match - all (clients)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records?filter=" + url.QueryEscape("rel_many_no_cascade_required.files:length=2"),
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
@@ -418,6 +443,32 @@ func TestRecordCrudList(t *testing.T) {
|
||||
"OnRecordsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo4/records",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordsListRequest().BindFunc(func(e *core.RecordsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
@@ -862,6 +913,32 @@ func TestRecordCrudView(t *testing.T) {
|
||||
"OnRecordEnrich": 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordViewRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordViewRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// auth collection
|
||||
// -----------------------------------------------------------
|
||||
@@ -1209,7 +1286,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterDeleteSuccessRequest error response",
|
||||
Name: "OnRecordDeleteRequest tx body write check",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
Headers: map[string]string{
|
||||
@@ -1217,15 +1294,22 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordDeleteRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordDeleteRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordDeleteRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
@@ -1563,10 +1647,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
Body: strings.NewReader(`{"title":"test123"}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "auth record submit in restricted collection (rule failure check)",
|
||||
@@ -1579,10 +1660,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "auth record submit in restricted collection (rule pass check) + expand relations",
|
||||
@@ -1731,10 +1809,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.body rule",
|
||||
@@ -1801,21 +1876,31 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterCreateSuccessRequest error response",
|
||||
Name: "OnRecordCreateRequest tx body write check",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordCreateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordCreateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
@@ -1974,14 +2059,9 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"total+":4,
|
||||
"total-":2
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "@request.body.field with compute modifers (rule pass check)",
|
||||
@@ -2813,21 +2893,31 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterUpdateSuccessRequest error response",
|
||||
Name: "OnRecordUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/collections/demo2/records/0yxhwia2amd8gec",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnRecordUpdateRequest().BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
return errors.New("error")
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordUpdateRequest": 1,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
{
|
||||
Name: "try to change the id of an existing record",
|
||||
|
||||
+56
-12
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -129,7 +130,9 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
result.Meta = e.Meta
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,7 +161,11 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -377,12 +384,18 @@ func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.Expan
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := core.NewRecordFieldResolver(app, relCollection, requestInfoPtr, true)
|
||||
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
q.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -455,18 +468,24 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
managedIds := []string{}
|
||||
|
||||
query := app.RecordQuery(collection).
|
||||
Select(app.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(app.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
Select(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(app.ConcurrentDB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Column(&managedIds); err != nil {
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = query.Column(&managedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// ---
|
||||
@@ -535,18 +554,43 @@ func firstApiError(errs ...error) *router.ApiError {
|
||||
return router.NewInternalServerError("", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// execAfterSuccessTx ensures that fn is executed only after a successful transaction.
|
||||
//
|
||||
// If the current app instance is not a transactional or checkTx is false,
|
||||
// then fn is directly executed.
|
||||
//
|
||||
// It could be usually used to allow propagating an error or writing
|
||||
// custom response from within the wrapped transaction block.
|
||||
func execAfterSuccessTx(checkTx bool, app core.App, fn func() error) error {
|
||||
if txInfo := app.TxInfo(); txInfo != nil && checkTx {
|
||||
txInfo.OnComplete(func(txErr error) error {
|
||||
if txErr == nil {
|
||||
return fn()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const maxAuthOrigins = 5
|
||||
|
||||
func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
// generating fingerprint
|
||||
// generate fingerprint
|
||||
// ---
|
||||
ip := e.RealIP()
|
||||
|
||||
userAgent := e.Request.UserAgent()
|
||||
if len(userAgent) > 300 {
|
||||
userAgent = userAgent[:300]
|
||||
if len(userAgent) > 200 {
|
||||
userAgent = userAgent[:200] + "..."
|
||||
}
|
||||
fingerprint := security.MD5(e.RealIP() + userAgent)
|
||||
|
||||
fingerprint := security.MD5(ip + userAgent)
|
||||
alertInfo := fmt.Sprintf("%s - %s %s", types.NowDateTime().String(), ip, userAgent)
|
||||
// ---
|
||||
|
||||
origins, err := e.App.FindAllAuthOriginsByRecord(authRecord)
|
||||
@@ -586,7 +630,7 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
})
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord)
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord, alertInfo)
|
||||
timer.Stop()
|
||||
mailSent <- err
|
||||
})
|
||||
|
||||
+44
-29
@@ -22,6 +22,8 @@ import (
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
const defaultCSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' http://127.0.0.1:*; frame-src 'none'"
|
||||
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
@@ -77,21 +79,25 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
// ignore root path
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
// @todo consider moving in base
|
||||
if ui.DistDirFS != nil {
|
||||
pbRouter.GET("/_/{path...}", Static(ui.DistDirFS, false)).
|
||||
BindFunc(func(e *core.RequestEvent) error {
|
||||
if !e.App.IsDev() &&
|
||||
// exclude root path
|
||||
e.Request.PathValue(StaticWildcardParam) != "" &&
|
||||
e.Response.Header().Get("Cache-Control") == "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
// add a default CSP
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data: blob:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='")
|
||||
}
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
}
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
@@ -143,9 +149,9 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
// higher defaults to accommodate large file uploads/downloads
|
||||
WriteTimeout: 3 * time.Minute,
|
||||
ReadTimeout: 3 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 1 * time.Minute,
|
||||
Addr: mainAddr,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return baseCtx
|
||||
@@ -153,13 +159,6 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
ErrorLog: log.New(&serverErrorLogWriter{app: app}, "", 0),
|
||||
}
|
||||
|
||||
serveEvent := new(core.ServeEvent)
|
||||
serveEvent.App = app
|
||||
serveEvent.Router = pbRouter
|
||||
serveEvent.Server = server
|
||||
serveEvent.CertManager = certManager
|
||||
serveEvent.InstallerFunc = DefaultInstallerFunc
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
// graceful shutdown
|
||||
@@ -207,6 +206,13 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
|
||||
var baseURL string
|
||||
|
||||
serveEvent := new(core.ServeEvent)
|
||||
serveEvent.App = app
|
||||
serveEvent.Router = pbRouter
|
||||
serveEvent.Server = server
|
||||
serveEvent.CertManager = certManager
|
||||
serveEvent.InstallerFunc = DefaultInstallerFunc
|
||||
|
||||
// trigger the OnServe hook and start the tcp listener
|
||||
serveHookErr := app.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||
handler, err := e.Router.BuildMux()
|
||||
@@ -237,9 +243,13 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
if e.Listener == nil {
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
listener = e.Listener
|
||||
}
|
||||
|
||||
if e.InstallerFunc != nil {
|
||||
@@ -259,7 +269,8 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
|
||||
if listener == nil {
|
||||
return errors.New("The OnServe finalizer wasn't invoked. Did you forget to call the ServeEvent.Next() method?")
|
||||
//nolint:staticcheck
|
||||
return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
|
||||
}
|
||||
|
||||
if config.ShowStartBanner {
|
||||
@@ -274,8 +285,12 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
|
||||
if ui.DistDirFS == nil {
|
||||
regular.Printf("└─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
} else {
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s/api/", baseURL))
|
||||
regular.Printf("└─ Dashboard: %s\n", color.CyanString("%s/_/", baseURL))
|
||||
}
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
|
||||
+12
-6
@@ -16,6 +16,8 @@ func bindSettingsApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup.PATCH("", settingsSet)
|
||||
subGroup.POST("/test/s3", settingsTestS3)
|
||||
subGroup.POST("/test/email", settingsTestEmail)
|
||||
|
||||
// @todo move to collections
|
||||
subGroup.POST("/apple/generate-client-secret", settingsGenerateAppleClientSecret)
|
||||
}
|
||||
|
||||
@@ -30,7 +32,9 @@ func settingsList(e *core.RequestEvent) error {
|
||||
event.Settings = clone
|
||||
|
||||
return e.App.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListRequestEvent) error {
|
||||
return e.JSON(http.StatusOK, e.Settings)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Settings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,12 +64,14 @@ func settingsSet(e *core.RequestEvent) error {
|
||||
return e.BadRequestError("An error occurred while saving the new settings.", err)
|
||||
}
|
||||
|
||||
appSettings, err := e.App.Settings().Clone()
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to clone app settings.", err)
|
||||
}
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
appSettings, err := e.App.Settings().Clone()
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to clone app settings.", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, appSettings)
|
||||
return e.JSON(http.StatusOK, appSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+76
-2
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,32 @@ func TestSettingsList(t *testing.T) {
|
||||
"OnSettingsListRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsListRequest tx body write check",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/settings",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnSettingsListRequest().BindFunc(func(e *core.SettingsListRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnSettingsListRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -70,8 +97,9 @@ func TestSettingsSet(t *testing.T) {
|
||||
|
||||
validData := `{
|
||||
"meta":{"appName":"update_test"},
|
||||
"s3":{"secret": "s3_secret"},
|
||||
"backups":{"s3":{"secret":"backups_s3_secret"}}
|
||||
"smtp":{"password": "new_smtp_password"},
|
||||
"s3":{"secret": "new_s3_secret"},
|
||||
"backups":{"s3":{"secret":"new_backups_s3_secret"}}
|
||||
}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -152,6 +180,25 @@ func TestSettingsSet(t *testing.T) {
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
settings := app.Settings()
|
||||
|
||||
// verify that the secret values are persisted
|
||||
secrets := map[string]struct {
|
||||
current string
|
||||
expected string
|
||||
}{
|
||||
"smtp.password": {settings.SMTP.Password, "new_smtp_password"},
|
||||
"s3.secret": {settings.S3.Secret, "new_s3_secret"},
|
||||
"backups.s3.secret": {settings.Backups.S3.Secret, "new_backups_s3_secret"},
|
||||
}
|
||||
|
||||
for name, secret := range secrets {
|
||||
if secret.current != secret.expected {
|
||||
t.Errorf("[%s] expected secret %q, got %q", name, secret.expected, secret.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"meta":{`,
|
||||
@@ -176,6 +223,33 @@ func TestSettingsSet(t *testing.T) {
|
||||
"OnSettingsReload": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsUpdateRequest tx body write check",
|
||||
Method: http.MethodPatch,
|
||||
URL: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnSettingsUpdateRequest().BindFunc(func(e *core.SettingsUpdateRequestEvent) error {
|
||||
original := e.App
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
e.App = txApp
|
||||
defer func() { e.App = original }()
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.BadRequestError("TX_ERROR", nil)
|
||||
})
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedEvents: map[string]int{"OnSettingsUpdateRequest": 1},
|
||||
ExpectedContent: []string{"TX_ERROR"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+19
-19
@@ -36,16 +36,16 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
|
||||
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(superusersCol, args[0])
|
||||
@@ -57,7 +57,7 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to upsert superuser account: %w.", err)
|
||||
return fmt.Errorf("failed to upsert superuser account: %w", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully saved superuser %q!", superuser.Email())
|
||||
@@ -76,16 +76,16 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superusersCol, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to fetch %q collection: %w.", core.CollectionNameSuperusers, err)
|
||||
return fmt.Errorf("failed to fetch %q collection: %w", core.CollectionNameSuperusers, err)
|
||||
}
|
||||
|
||||
superuser := core.NewRecord(superusersCol)
|
||||
@@ -93,7 +93,7 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to create new superuser account: %w.", err)
|
||||
return fmt.Errorf("failed to create new superuser account: %w", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully created new superuser %q!", superuser.Email())
|
||||
@@ -112,22 +112,22 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("Missing email and password arguments.")
|
||||
return errors.New("missing email and password arguments")
|
||||
}
|
||||
|
||||
if args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Missing or invalid email address.")
|
||||
return errors.New("missing or invalid email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
|
||||
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
|
||||
}
|
||||
|
||||
superuser.SetPassword(args[1])
|
||||
|
||||
if err := app.Save(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to change superuser %q password: %w.", superuser.Email(), err)
|
||||
return fmt.Errorf("failed to change superuser %q password: %w", superuser.Email(), err)
|
||||
}
|
||||
|
||||
color.Green("Successfully changed superuser %q password!", superuser.Email())
|
||||
@@ -146,17 +146,17 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
return errors.New("invalid or missing email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
color.Yellow("Superuser %q is missing or already deleted.", args[0])
|
||||
color.Yellow("superuser %q is missing or already deleted", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := app.Delete(superuser); err != nil {
|
||||
return fmt.Errorf("Failed to delete superuser %q: %w.", superuser.Email(), err)
|
||||
return fmt.Errorf("failed to delete superuser %q: %w", superuser.Email(), err)
|
||||
}
|
||||
|
||||
color.Green("Successfully deleted superuser %q!", superuser.Email())
|
||||
@@ -175,16 +175,16 @@ func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 || args[0] == "" || is.EmailFormat.Validate(args[0]) != nil {
|
||||
return errors.New("Invalid or missing email address.")
|
||||
return errors.New("invalid or missing email address")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Superuser with email %q doesn't exist.", args[0])
|
||||
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
|
||||
}
|
||||
|
||||
if !superuser.Collection().OTP.Enabled {
|
||||
return errors.New("OTP is not enabled for the _superusers collection.")
|
||||
return errors.New("OTP auth is not enabled for the _superusers collection")
|
||||
}
|
||||
|
||||
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
|
||||
@@ -196,7 +196,7 @@ func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
|
||||
err = app.Save(otp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create OTP: %w", err)
|
||||
return fmt.Errorf("failed to create OTP: %w", err)
|
||||
}
|
||||
|
||||
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
|
||||
|
||||
+83
-31
@@ -45,6 +45,12 @@ type App interface {
|
||||
// IsTransactional checks if the current app instance is part of a transaction.
|
||||
IsTransactional() bool
|
||||
|
||||
// TxInfo returns the transaction associated with the current app instance (if any).
|
||||
//
|
||||
// Could be used if you want to execute indirectly a function after
|
||||
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
|
||||
TxInfo() *TxAppInfo
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
@@ -91,7 +97,7 @@ type App interface {
|
||||
// after you are done working with it.
|
||||
NewFilesystem() (*filesystem.System, error)
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// NewBackupsFilesystem 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
|
||||
@@ -140,46 +146,82 @@ type App interface {
|
||||
// DB methods
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// DB returns the default app data db instance (pb_data/data.db).
|
||||
// DB returns the default app data.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything else
|
||||
// to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly ConcurrentDB() or NonconcurrentDB().
|
||||
DB() dbx.Builder
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
|
||||
// ConcurrentDB returns the concurrent app data.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
ConcurrentDB() dbx.Builder
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
NonconcurrentDB() dbx.Builder
|
||||
|
||||
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxDB returns the app auxiliary.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything else
|
||||
// to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
AuxDB() dbx.Builder
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db)..
|
||||
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
AuxConcurrentDB() dbx.Builder
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
AuxNonconcurrentDB() dbx.Builder
|
||||
|
||||
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
||||
// in the current app.DB() instance.
|
||||
// in the data.db.
|
||||
HasTable(tableName string) bool
|
||||
|
||||
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
|
||||
// in the current app.AuxDB() instance.
|
||||
// in the auxiliary.db.
|
||||
AuxHasTable(tableName string) bool
|
||||
|
||||
// TableColumns returns all column names of a single table by its name.
|
||||
@@ -198,48 +240,58 @@ type App interface {
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
DeleteTable(tableName string) error
|
||||
// "dangerousTableName" argument must come only from trusted input!
|
||||
DeleteTable(dangerousTableName string) error
|
||||
|
||||
// DeleteView drops the specified view name.
|
||||
//
|
||||
// This method is a no-op if a view with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "name" argument must come only from trusted input!
|
||||
DeleteView(name string) error
|
||||
// "dangerousViewName" argument must come only from trusted input!
|
||||
DeleteView(dangerousViewName string) error
|
||||
|
||||
// SaveView creates (or updates already existing) persistent SQL view.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "selectQuery" argument must come only from trusted input!
|
||||
SaveView(name string, selectQuery string) error
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and
|
||||
// its arguments must come only from trusted input!
|
||||
SaveView(dangerousViewName string, dangerousSelectQuery string) error
|
||||
|
||||
// CreateViewFields creates a new FieldsList from the provided select query.
|
||||
//
|
||||
// There are some caveats:
|
||||
// - The select query must have an "id" column.
|
||||
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
|
||||
CreateViewFields(selectQuery string) (FieldsList, error)
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
CreateViewFields(dangerousSelectQuery string) (FieldsList, error)
|
||||
|
||||
// DryRunView executes the provided query by creating a temporary view
|
||||
// collection and returning a sample of the resulting query records (if valid).
|
||||
//
|
||||
// The same caveats from CreateViewFields apply here too.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "dangerousSelectQuery" argument must come only from trusted input!
|
||||
DryRunView(dangerousSelectQuery string, sampleSize int) (*DryRunViewResult, error)
|
||||
|
||||
// FindRecordByViewFile returns the original Record of the provided view collection file.
|
||||
FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error)
|
||||
|
||||
// Vacuum executes VACUUM on the current app.DB() instance
|
||||
// in order to reclaim unused data db disk space.
|
||||
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
|
||||
Vacuum() error
|
||||
|
||||
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
|
||||
// in order to reclaim unused auxiliary db disk space.
|
||||
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
|
||||
AuxVacuum() error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// ModelQuery creates a new preconfigured select app.DB() query with preset
|
||||
// ModelQuery creates a new preconfigured select data.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
ModelQuery(model Model) *dbx.SelectQuery
|
||||
|
||||
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
|
||||
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
AuxModelQuery(model Model) *dbx.SelectQuery
|
||||
|
||||
@@ -323,10 +375,10 @@ type App interface {
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
FindLogById(id string) (*Log, error)
|
||||
|
||||
// LogsStatsItem defines the total number of logs for a specific time period.
|
||||
// LogsStatsItem returns hourly grouped logs statistics.
|
||||
LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error)
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
// DeleteOldLogs delete all logs that are created before createdBefore.
|
||||
DeleteOldLogs(createdBefore time.Time) error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1155,7 +1207,7 @@ type App interface {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnMailerSend hook is triggered every time when a new email is
|
||||
// being send using the [App.NewMailClient()] instance.
|
||||
// being sent using the [App.NewMailClient()] instance.
|
||||
//
|
||||
// It allows intercepting the email message or to use a custom mailer client.
|
||||
OnMailerSend() *hook.Hook[*MailerEvent]
|
||||
|
||||
+162
-46
@@ -12,7 +12,6 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -26,6 +25,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,6 +40,9 @@ const (
|
||||
LocalBackupsDirName string = "backups"
|
||||
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
||||
LocalAutocertCacheDirName string = ".autocert_cache"
|
||||
|
||||
// @todo consider removing after backups refactoring
|
||||
lostFoundDirName string = "lost+found"
|
||||
)
|
||||
|
||||
// FilesManager defines an interface with common methods that files manager models should implement.
|
||||
@@ -69,7 +73,7 @@ var _ App = (*BaseApp)(nil)
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
config *BaseAppConfig
|
||||
txInfo *txAppInfo
|
||||
txInfo *TxAppInfo
|
||||
store *store.Store[string, any]
|
||||
cron *cron.Cron
|
||||
settings *Settings
|
||||
@@ -360,9 +364,17 @@ func (app *BaseApp) Logger() *slog.Logger {
|
||||
return app.logger
|
||||
}
|
||||
|
||||
// TxInfo returns the transaction associated with the current app instance (if any).
|
||||
//
|
||||
// Could be used if you want to execute indirectly a function after
|
||||
// the related app transaction completes using `app.TxInfo().OnAfterFunc(callback)`.
|
||||
func (app *BaseApp) TxInfo() *TxAppInfo {
|
||||
return app.txInfo
|
||||
}
|
||||
|
||||
// IsTransactional checks if the current app instance is part of a transaction.
|
||||
func (app *BaseApp) IsTransactional() bool {
|
||||
return app.txInfo != nil
|
||||
return app.TxInfo() != nil
|
||||
}
|
||||
|
||||
// IsBootstrapped checks if the application was initialized
|
||||
@@ -466,44 +478,100 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB returns the default app data db instance (pb_data/data.db).
|
||||
// DB returns the default app data.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything
|
||||
// else to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly ConcurrentDB() or NonconcurrentDB().
|
||||
func (app *BaseApp) DB() dbx.Builder {
|
||||
// transactional or both are nil
|
||||
if app.concurrentDB == app.nonconcurrentDB {
|
||||
return app.concurrentDB
|
||||
}
|
||||
|
||||
return &dualDBBuilder{
|
||||
concurrentDB: app.concurrentDB,
|
||||
nonconcurrentDB: app.nonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// ConcurrentDB returns the concurrent app data.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) ConcurrentDB() dbx.Builder {
|
||||
return app.concurrentDB
|
||||
}
|
||||
|
||||
// NonconcurrentDB returns the nonconcurrent app data db instance (pb_data/data.db).
|
||||
// NonconcurrentDB returns the nonconcurrent app data.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply DB() as it will automatically
|
||||
// route the query execution to ConcurrentDB() or NonconcurrentDB().
|
||||
//
|
||||
// In a transaction the ConcurrentDB() and NonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) NonconcurrentDB() dbx.Builder {
|
||||
return app.nonconcurrentDB
|
||||
}
|
||||
|
||||
// AuxDB returns the default app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxDB returns the app auxiliary.db builder instance.
|
||||
//
|
||||
// To minimize SQLITE_BUSY errors, it automatically routes the
|
||||
// SELECT queries to the underlying concurrent db pool and everything
|
||||
// else to the nonconcurrent one.
|
||||
//
|
||||
// For more finer control over the used connections pools you can
|
||||
// call directly AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
func (app *BaseApp) AuxDB() dbx.Builder {
|
||||
// transactional or both are nil
|
||||
if app.auxConcurrentDB == app.auxNonconcurrentDB {
|
||||
return app.auxConcurrentDB
|
||||
}
|
||||
|
||||
return &dualDBBuilder{
|
||||
concurrentDB: app.auxConcurrentDB,
|
||||
nonconcurrentDB: app.auxNonconcurrentDB,
|
||||
}
|
||||
}
|
||||
|
||||
// AuxConcurrentDB returns the concurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// This method is used mainly internally for executing db read
|
||||
// operations in a concurrent/non-blocking manner.
|
||||
//
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) AuxConcurrentDB() dbx.Builder {
|
||||
return app.auxConcurrentDB
|
||||
}
|
||||
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary db instance (pb_data/auxiliary.db).
|
||||
// AuxNonconcurrentDB returns the nonconcurrent app auxiliary.db builder instance.
|
||||
//
|
||||
// The returned db instance is limited only to a single open connection,
|
||||
// meaning that it can process only 1 db operation at a time (other operations will be queued up).
|
||||
// meaning that it can process only 1 db operation at a time (other queries queue up).
|
||||
//
|
||||
// This method is used mainly internally and in the tests to execute write
|
||||
// (save/delete) db operations as it helps with minimizing the SQLITE_BUSY errors.
|
||||
//
|
||||
// For the majority of cases you would want to use the regular DB() method
|
||||
// since it allows concurrent db read operations.
|
||||
// Most users should use simply AuxDB() as it will automatically
|
||||
// route the query execution to AuxConcurrentDB() or AuxNonconcurrentDB().
|
||||
//
|
||||
// In a transaction the AuxNonconcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
// In a transaction the AuxConcurrentDB() and AuxNonconcurrentDB() refer to the same *dbx.TX instance.
|
||||
func (app *BaseApp) AuxNonconcurrentDB() dbx.Builder {
|
||||
return app.auxNonconcurrentDB
|
||||
}
|
||||
@@ -659,7 +727,7 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
}
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// NewBackupsFilesystem 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
|
||||
@@ -707,7 +775,7 @@ func (app *BaseApp) Restart() error {
|
||||
}
|
||||
}()
|
||||
|
||||
return syscall.Exec(execPath, os.Args, os.Environ())
|
||||
return execve(execPath, os.Args, os.Environ())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1139,26 +1207,25 @@ func (app *BaseApp) initDataDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sqlLogReplacements = map[string]string{
|
||||
"{{": "`",
|
||||
"}}": "`",
|
||||
"[[": "`",
|
||||
"]]": "`",
|
||||
"<nil>": "NULL",
|
||||
var sqlLogReplacements = []struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
}{
|
||||
{regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`), "`$1`.`$2`"},
|
||||
{regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`), "`$1`.`$2`"},
|
||||
{regexp.MustCompile(`([^'"])?\{\{`), "$1`"},
|
||||
{regexp.MustCompile(`\}\}([^'"])?`), "`$1"},
|
||||
{regexp.MustCompile(`([^'"])?\[\[`), "$1`"},
|
||||
{regexp.MustCompile(`\]\]([^'"])?`), "`$1"},
|
||||
{regexp.MustCompile(`<nil>`), "NULL"},
|
||||
}
|
||||
var sqlLogPrefixedTableIdentifierPattern = regexp.MustCompile(`\[\[([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\]\]`)
|
||||
var sqlLogPrefixedColumnIdentifierPattern = regexp.MustCompile(`\{\{([^\[\]\{\}\.]+)\.([^\[\]\{\}\.]+)\}\}`)
|
||||
|
||||
// normalizeSQLLog replaces common query builder charactes with their plain SQL version for easier debugging.
|
||||
// normalizeSQLLog replaces common query builder characters with their plain SQL version for easier debugging.
|
||||
// The query is still not suitable for execution and should be used only for log and debug purposes
|
||||
// (the normalization is done here to avoid breaking changes in dbx).
|
||||
func normalizeSQLLog(sql string) string {
|
||||
sql = sqlLogPrefixedTableIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
|
||||
|
||||
sql = sqlLogPrefixedColumnIdentifierPattern.ReplaceAllString(sql, "`$1`.`$2`")
|
||||
|
||||
for old, new := range sqlLogReplacements {
|
||||
sql = strings.ReplaceAll(sql, old, new)
|
||||
for _, item := range sqlLogReplacements {
|
||||
sql = item.pattern.ReplaceAllString(sql, item.replacement)
|
||||
}
|
||||
|
||||
return sql
|
||||
@@ -1191,6 +1258,33 @@ func (app *BaseApp) initAuxDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @todo remove after refactoring the FilesManager interface
|
||||
func supportFiles(m Model) bool {
|
||||
var collection *Collection
|
||||
switch v := m.(type) {
|
||||
case *Collection:
|
||||
collection = v
|
||||
case *Record:
|
||||
collection = v.Collection()
|
||||
case RecordProxy:
|
||||
if v.ProxyRecord() != nil {
|
||||
collection = v.ProxyRecord().Collection()
|
||||
}
|
||||
}
|
||||
|
||||
if collection == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, f := range collection.Fields {
|
||||
if f.Type() == FieldTypeFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *BaseApp) registerBaseHooks() {
|
||||
deletePrefix := func(prefix string) error {
|
||||
fs, err := app.NewFilesystem()
|
||||
@@ -1207,26 +1301,44 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxFilesDeleteWorkers := cast.ToInt64(os.Getenv("PB_FILES_DELETE_MAX_WORKERS"))
|
||||
if maxFilesDeleteWorkers <= 0 {
|
||||
maxFilesDeleteWorkers = 2000 // the value is arbitrary chosen and may change in the future
|
||||
}
|
||||
|
||||
deleteSem := semaphore.NewWeighted(maxFilesDeleteWorkers)
|
||||
|
||||
// try to delete the storage files from deleted Collection, Records, etc. model
|
||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*ModelEvent]{
|
||||
Id: "__pbFilesManagerDelete__",
|
||||
Func: func(e *ModelEvent) error {
|
||||
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" {
|
||||
if m, ok := e.Model.(FilesManager); ok && m.BaseFilesPath() != "" && supportFiles(e.Model) {
|
||||
// ensure that there is a trailing slash so that the list iterator could start walking from the prefix dir
|
||||
// (https://github.com/pocketbase/pocketbase/discussions/5246#discussioncomment-10128955)
|
||||
prefix := strings.TrimRight(m.BaseFilesPath(), "/") + "/"
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
routine.FireAndForget(func() {
|
||||
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()),
|
||||
)
|
||||
}
|
||||
})
|
||||
// note: for now assume no context cancellation
|
||||
err := deleteSem.Acquire(context.Background(), 1)
|
||||
if err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (couldn't acquire a worker)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
} else {
|
||||
// run in the background for "optimistic" delete to avoid blocking the delete transaction
|
||||
routine.FireAndForget(func() {
|
||||
defer deleteSem.Release(1)
|
||||
|
||||
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()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
@@ -1255,7 +1367,7 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
_, execErr = app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
||||
}
|
||||
@@ -1296,7 +1408,7 @@ func getLoggerMinLevel(app App) slog.Level {
|
||||
func (app *BaseApp) initLogger() error {
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool)
|
||||
done := make(chan bool, 1)
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: getLoggerMinLevel(app),
|
||||
@@ -1367,7 +1479,11 @@ func (app *BaseApp) initLogger() error {
|
||||
|
||||
ticker.Stop()
|
||||
|
||||
done <- true
|
||||
// don't block in case OnTerminate is triggered more than once
|
||||
select {
|
||||
case done <- true:
|
||||
default:
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
|
||||
+38
-24
@@ -54,7 +54,7 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup generation
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
|
||||
|
||||
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
|
||||
// generate a default name if missing
|
||||
@@ -69,9 +69,9 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
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
|
||||
// archive pb_data in a temp directory, excluding the "backups" and the temp dirs
|
||||
//
|
||||
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
|
||||
// run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection)
|
||||
// ---
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(6))
|
||||
createErr := e.App.RunInTransaction(func(txApp App) error {
|
||||
@@ -138,9 +138,9 @@ 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 successful app bootstap it will also remove the old pb_data).
|
||||
// 5. Restart the app (on successful app bootstrap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If a failure occur during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
//
|
||||
// Note that if your pb_data has custom network mounts as subdirectories, then
|
||||
@@ -159,7 +159,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
event.Context = ctx
|
||||
event.Name = name
|
||||
// default root dir entries to exclude from the backup restore
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName}
|
||||
event.Exclude = []string{LocalBackupsDirName, LocalTempDirName, LocalAutocertCacheDirName, lostFoundDirName}
|
||||
|
||||
return app.OnBackupRestore().Trigger(event, func(e *BackupEvent) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -190,7 +190,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// extract the zip
|
||||
if e.App.Settings().Backups.S3.Enabled {
|
||||
br, err := fsys.GetFile(name)
|
||||
br, err := fsys.GetReader(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
} else {
|
||||
// manually construct the local path to avoid creating a copy of the zip file
|
||||
// since the blob reader currently doesn't implement ReaderAt
|
||||
zipPath := filepath.Join(app.DataDir(), LocalBackupsDirName, filepath.Base(name))
|
||||
zipPath := filepath.Join(e.App.DataDir(), LocalBackupsDirName, filepath.Base(name))
|
||||
|
||||
err = archive.Extract(zipPath, extractedDataDir)
|
||||
if err != nil {
|
||||
@@ -242,29 +242,43 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
||||
}
|
||||
|
||||
// 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(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
|
||||
if err := osutils.MoveDirContent(e.App.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
|
||||
// move the extracted archive content to the app's pb_data
|
||||
if err := osutils.MoveDirContent(extractedDataDir, e.App.DataDir(), e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
|
||||
replaceErr := e.App.RunInTransaction(func(txApp App) error {
|
||||
return txApp.AuxRunInTransaction(func(txApp App) 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)
|
||||
if err := osutils.MoveDirContent(txApp.DataDir(), oldTempDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the current pb_data content to a temp location: %w", err)
|
||||
}
|
||||
|
||||
// move the extracted archive content to the app's pb_data
|
||||
if err := osutils.MoveDirContent(extractedDataDir, txApp.DataDir(), e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
|
||||
revertDataDirChanges := func() error {
|
||||
if err := osutils.MoveDirContent(e.App.DataDir(), extractedDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
|
||||
}
|
||||
return e.App.RunInTransaction(func(txApp App) error {
|
||||
return txApp.AuxRunInTransaction(func(txApp App) error {
|
||||
if err := osutils.MoveDirContent(txApp.DataDir(), extractedDataDir, e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
|
||||
}
|
||||
|
||||
if err := osutils.MoveDirContent(oldTempDataDir, e.App.DataDir(), e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
|
||||
}
|
||||
if err := osutils.MoveDirContent(oldTempDataDir, txApp.DataDir(), e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// restart the app
|
||||
|
||||
+201
-11
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,9 +100,11 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
}
|
||||
|
||||
nilChecksBeforeReset := []nilCheck{
|
||||
{"[before] concurrentDB", app.DB(), false},
|
||||
{"[before] db", app.DB(), false},
|
||||
{"[before] concurrentDB", app.ConcurrentDB(), false},
|
||||
{"[before] nonconcurrentDB", app.NonconcurrentDB(), false},
|
||||
{"[before] auxConcurrentDB", app.AuxDB(), false},
|
||||
{"[before] auxDB", app.AuxDB(), false},
|
||||
{"[before] auxConcurrentDB", app.AuxConcurrentDB(), false},
|
||||
{"[before] auxNonconcurrentDB", app.AuxNonconcurrentDB(), false},
|
||||
{"[before] settings", app.Settings(), false},
|
||||
{"[before] logger", app.Logger(), false},
|
||||
@@ -116,9 +119,11 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
}
|
||||
|
||||
nilChecksAfterReset := []nilCheck{
|
||||
{"[after] concurrentDB", app.DB(), true},
|
||||
{"[after] db", app.DB(), true},
|
||||
{"[after] concurrentDB", app.ConcurrentDB(), true},
|
||||
{"[after] nonconcurrentDB", app.NonconcurrentDB(), true},
|
||||
{"[after] auxConcurrentDB", app.AuxDB(), true},
|
||||
{"[after] auxDB", app.AuxDB(), true},
|
||||
{"[after] auxConcurrentDB", app.AuxConcurrentDB(), true},
|
||||
{"[after] auxNonconcurrentDB", app.AuxNonconcurrentDB(), true},
|
||||
{"[after] settings", app.Settings(), false},
|
||||
{"[after] logger", app.Logger(), false},
|
||||
@@ -128,7 +133,7 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
runNilChecks(nilChecksAfterReset)
|
||||
}
|
||||
|
||||
func TestNewBaseAppIsTransactional(t *testing.T) {
|
||||
func TestNewBaseAppTx(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
@@ -141,17 +146,34 @@ func TestNewBaseAppIsTransactional(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if app.IsTransactional() {
|
||||
t.Fatalf("Didn't expect the app to be transactional")
|
||||
mustNotHaveTx := func(app core.App) {
|
||||
if app.IsTransactional() {
|
||||
t.Fatalf("Didn't expect the app to be transactional")
|
||||
}
|
||||
|
||||
if app.TxInfo() != nil {
|
||||
t.Fatalf("Didn't expect the app.txInfo to be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
if !txApp.IsTransactional() {
|
||||
mustHaveTx := func(app core.App) {
|
||||
if !app.IsTransactional() {
|
||||
t.Fatalf("Expected the app to be transactional")
|
||||
}
|
||||
|
||||
if app.TxInfo() == nil {
|
||||
t.Fatalf("Expected the app.txInfo to be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
mustNotHaveTx(app)
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
mustHaveTx(txApp)
|
||||
return nil
|
||||
})
|
||||
|
||||
mustNotHaveTx(app)
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
@@ -354,8 +376,8 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// silence query logs
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {}
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {}
|
||||
|
||||
@@ -378,3 +400,171 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppDBDualBuilder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
concurrentQueries := []string{}
|
||||
nonconcurrentQueries := []string{}
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
|
||||
type testQuery struct {
|
||||
query string
|
||||
isConcurrent bool
|
||||
}
|
||||
|
||||
regularTests := []testQuery{
|
||||
{" \n sEleCt 1", true},
|
||||
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
|
||||
{"create table t1(x int)", false},
|
||||
{"insert into t1(x) values(1)", false},
|
||||
{"update t1 set x = 2", false},
|
||||
{"delete from t1", false},
|
||||
}
|
||||
|
||||
txTests := []testQuery{
|
||||
{"select 3", false},
|
||||
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
|
||||
{"create table t2(x int)", false},
|
||||
{"insert into t2(x) values(1)", false},
|
||||
{"update t2 set x = 2", false},
|
||||
{"delete from t2", false},
|
||||
}
|
||||
|
||||
for _, item := range regularTests {
|
||||
_, err := app.DB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.RunInTransaction(func(txApp core.App) error {
|
||||
for _, item := range txTests {
|
||||
_, err := txApp.DB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
allTests := append(regularTests, txTests...)
|
||||
for _, item := range allTests {
|
||||
if item.isConcurrent {
|
||||
if !slices.Contains(concurrentQueries, item.query) {
|
||||
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
} else {
|
||||
if !slices.Contains(nonconcurrentQueries, item.query) {
|
||||
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppAuxDBDualBuilder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
concurrentQueries := []string{}
|
||||
nonconcurrentQueries := []string{}
|
||||
app.AuxConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.AuxConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
concurrentQueries = append(concurrentQueries, sql)
|
||||
}
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
nonconcurrentQueries = append(nonconcurrentQueries, sql)
|
||||
}
|
||||
|
||||
type testQuery struct {
|
||||
query string
|
||||
isConcurrent bool
|
||||
}
|
||||
|
||||
regularTests := []testQuery{
|
||||
{" \n sEleCt 1", true},
|
||||
{"With abc(x) AS (select 2) SELECT x FROM abc", true},
|
||||
{"create table t1(x int)", false},
|
||||
{"insert into t1(x) values(1)", false},
|
||||
{"update t1 set x = 2", false},
|
||||
{"delete from t1", false},
|
||||
}
|
||||
|
||||
txTests := []testQuery{
|
||||
{"select 3", false},
|
||||
{" \n WITH abc(x) AS (select 4) SELECT x FROM abc", false},
|
||||
{"create table t2(x int)", false},
|
||||
{"insert into t2(x) values(1)", false},
|
||||
{"update t2 set x = 2", false},
|
||||
{"delete from t2", false},
|
||||
}
|
||||
|
||||
for _, item := range regularTests {
|
||||
_, err := app.AuxDB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.AuxRunInTransaction(func(txApp core.App) error {
|
||||
for _, item := range txTests {
|
||||
_, err := txApp.AuxDB().NewQuery(item.query).Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute query %q error: %v", item.query, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
allTests := append(regularTests, txTests...)
|
||||
for _, item := range allTests {
|
||||
if item.isConcurrent {
|
||||
if !slices.Contains(concurrentQueries, item.query) {
|
||||
t.Fatalf("Expected concurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
} else {
|
||||
if !slices.Contains(nonconcurrentQueries, item.query) {
|
||||
t.Fatalf("Expected nonconcurrent query\n%q\ngot\nconcurrent:%v\nnonconcurrent:%v", item.query, concurrentQueries, nonconcurrentQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppTriggerOnTerminate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
event := new(core.TerminateEvent)
|
||||
event.App = app
|
||||
|
||||
// trigger OnTerminate multiple times to ensure that it doesn't deadlock
|
||||
// https://github.com/pocketbase/pocketbase/pull/7305
|
||||
app.OnTerminate().Trigger(event)
|
||||
app.OnTerminate().Trigger(event)
|
||||
app.OnTerminate().Trigger(event)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestImportCollections(t *testing.T) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
core.CollectionNameAuthOrigins: 6,
|
||||
"nologin": 10,
|
||||
"demo1": 18,
|
||||
"demo1": 19,
|
||||
"demo2": 5,
|
||||
"demo3": 5,
|
||||
"demo4": 16,
|
||||
|
||||
@@ -545,7 +545,7 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
// MarshalJSON implements the [json.Marshaler] interface.
|
||||
//
|
||||
// Note that non-type related fields are ignored from the serialization
|
||||
// (ex. for "view" colections the "auth" fields are skipped).
|
||||
// (ex. for "view" collections the "auth" fields are skipped).
|
||||
func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
switch m.Type {
|
||||
case CollectionTypeView:
|
||||
@@ -559,19 +559,26 @@ func (m Collection) MarshalJSON() ([]byte, error) {
|
||||
collectionAuthOptions
|
||||
}{m.baseCollection, m.collectionAuthOptions}
|
||||
|
||||
// ensure that it is always returned as array
|
||||
if alias.OAuth2.Providers == nil {
|
||||
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
|
||||
}
|
||||
|
||||
// @todo to avoid the below changes consider omitting the field values from the individual structs json tags
|
||||
//
|
||||
// hide secret keys from the serialization
|
||||
alias.AuthToken.Secret = ""
|
||||
alias.FileToken.Secret = ""
|
||||
alias.PasswordResetToken.Secret = ""
|
||||
alias.EmailChangeToken.Secret = ""
|
||||
alias.VerificationToken.Secret = ""
|
||||
for i := range alias.OAuth2.Providers {
|
||||
alias.OAuth2.Providers[i].ClientSecret = ""
|
||||
|
||||
if alias.OAuth2.Providers == nil {
|
||||
// ensure that it is always returned as array
|
||||
alias.OAuth2.Providers = []OAuth2ProviderConfig{}
|
||||
} else {
|
||||
// create a deep copy of the slice to avoid modifying the cached model state
|
||||
redactedProviders := make([]OAuth2ProviderConfig, len(alias.OAuth2.Providers))
|
||||
copy(redactedProviders, alias.OAuth2.Providers)
|
||||
for i := range redactedProviders {
|
||||
redactedProviders[i].ClientSecret = ""
|
||||
}
|
||||
alias.OAuth2.Providers = redactedProviders
|
||||
}
|
||||
|
||||
return json.Marshal(alias)
|
||||
@@ -1062,7 +1069,7 @@ func (c *Collection) fieldIndexName(field string) string {
|
||||
} else if c.Name != "" {
|
||||
name += c.Name
|
||||
} else {
|
||||
name += security.PseudorandomString(10)
|
||||
name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
|
||||
}
|
||||
|
||||
if len(name) > 64 {
|
||||
|
||||
@@ -2,11 +2,12 @@ package core
|
||||
|
||||
// Common settings placeholder tokens
|
||||
const (
|
||||
EmailPlaceholderAppName string = "{APP_NAME}"
|
||||
EmailPlaceholderAppURL string = "{APP_URL}"
|
||||
EmailPlaceholderToken string = "{TOKEN}"
|
||||
EmailPlaceholderOTP string = "{OTP}"
|
||||
EmailPlaceholderOTPId string = "{OTP_ID}"
|
||||
EmailPlaceholderAppName string = "{APP_NAME}"
|
||||
EmailPlaceholderAppURL string = "{APP_URL}"
|
||||
EmailPlaceholderToken string = "{TOKEN}"
|
||||
EmailPlaceholderOTP string = "{OTP}"
|
||||
EmailPlaceholderOTPId string = "{OTP_ID}"
|
||||
EmailPlaceholderAlertInfo string = "{ALERT_INFO}"
|
||||
)
|
||||
|
||||
var defaultVerificationTemplate = EmailTemplate{
|
||||
@@ -65,9 +66,10 @@ var defaultOTPTemplate = EmailTemplate{
|
||||
var defaultAuthAlertTemplate = EmailTemplate{
|
||||
Subject: "Login from a new location",
|
||||
Body: `<p>Hello,</p>
|
||||
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location.</p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p>We noticed a login to your ` + EmailPlaceholderAppName + ` account from a new location:</p>
|
||||
<p><em>` + EmailPlaceholderAlertInfo + `</em></p>
|
||||
<p><strong>If this wasn't you, you should immediately change your ` + EmailPlaceholderAppName + ` account password to revoke access from all other locations.</strong></p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
|
||||
+347
-27
@@ -1,9 +1,12 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
@@ -756,6 +760,46 @@ func TestCollectionSerialize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionSerializeNotModifyingCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
c, err := app.FindCachedCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
redactedFields := map[string]string{
|
||||
"AuthToken.Secret": c.AuthToken.Secret,
|
||||
"FileToken.Secret": c.FileToken.Secret,
|
||||
"PasswordResetToken.Secret": c.PasswordResetToken.Secret,
|
||||
"EmailChangeToken.Secret": c.EmailChangeToken.Secret,
|
||||
"VerificationToken.Secret": c.VerificationToken.Secret,
|
||||
}
|
||||
|
||||
if len(c.OAuth2.Providers) == 0 {
|
||||
t.Fatal("Expected at least one users OAuth2 provider, got 0")
|
||||
}
|
||||
for _, p := range c.OAuth2.Providers {
|
||||
redactedFields[p.Name+".ClientSecret"] = p.ClientSecret
|
||||
}
|
||||
|
||||
for k, v := range redactedFields {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
if v == "" {
|
||||
t.Fatalf("Expected the redacted field %q to remain unmodified after serialization, got empty value", k)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionDBExport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -773,19 +817,19 @@ func TestCollectionDBExport(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
"unknown",
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"unknown","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeBase,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":"{}","system":true,"type":"base","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeView,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"viewQuery":"select 1"},"system":true,"type":"view","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
{
|
||||
core.CollectionTypeAuth,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
`{"createRule":"1=3","created":"2024-07-01 01:02:03.456Z","deleteRule":"1=5","fields":[{"help":"","hidden":false,"id":"f1_id","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"help":"","hidden":false,"id":"f2_id","name":"f2","presentable":false,"required":true,"system":false,"type":"bool"}],"id":"test_id","indexes":["CREATE INDEX idx1 on test_name(id)","CREATE INDEX idx2 on test_name(id)"],"listRule":"1=1","name":"test_name","options":{"authRule":null,"manageRule":"1=6","authAlert":{"enabled":false,"emailTemplate":{"subject":"","body":""}},"oauth2":{"providers":null,"mappedFields":{"id":"","name":"","username":"","avatarURL":""},"enabled":false},"passwordAuth":{"enabled":false,"identityFields":null},"mfa":{"enabled":false,"duration":0,"rule":""},"otp":{"enabled":false,"duration":0,"length":0,"emailTemplate":{"subject":"","body":""}},"authToken":{"duration":0},"passwordResetToken":{"duration":0},"emailChangeToken":{"duration":0},"verificationToken":{"duration":0},"fileToken":{"duration":0},"verificationTemplate":{"subject":"","body":""},"resetPasswordTemplate":{"subject":"","body":""},"confirmEmailChangeTemplate":{"subject":"","body":""}},"system":true,"type":"auth","updateRule":"1=4","updated":"2024-07-01 01:02:03.456Z","viewRule":"1=7"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -976,6 +1020,280 @@ func TestCollectionDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionModelEventSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
testCollections := make([]*core.Collection, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
testCollections[i] = core.NewBaseCollection("sync_test_" + strconv.Itoa(i))
|
||||
if err := app.Save(testCollections[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
createModelEvent := func() *core.ModelEvent {
|
||||
event := new(core.ModelEvent)
|
||||
event.App = app
|
||||
event.Context = context.Background()
|
||||
event.Type = "test_a"
|
||||
event.Model = testCollections[0]
|
||||
return event
|
||||
}
|
||||
|
||||
createModelErrorEvent := func() *core.ModelErrorEvent {
|
||||
event := new(core.ModelErrorEvent)
|
||||
event.ModelEvent = *createModelEvent()
|
||||
event.Error = errors.New("error_a")
|
||||
return event
|
||||
}
|
||||
|
||||
changeCollectionEventBefore := func(e *core.CollectionEvent) {
|
||||
e.Type = "test_b"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 123)
|
||||
e.Collection = testCollections[1]
|
||||
}
|
||||
|
||||
modelEventFinalizerChange := func(e *core.ModelEvent) {
|
||||
e.Type = "test_c"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 456)
|
||||
e.Model = testCollections[2]
|
||||
}
|
||||
|
||||
changeCollectionEventAfter := func(e *core.CollectionEvent) {
|
||||
e.Type = "test_d"
|
||||
//nolint:staticcheck
|
||||
e.Context = context.WithValue(context.Background(), "test", 789)
|
||||
e.Collection = testCollections[3]
|
||||
}
|
||||
|
||||
expectedBeforeModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Model.PK() != testCollections[0].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Model.PK(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterModelEventHandlerChecks := func(t *testing.T, e *core.ModelEvent) {
|
||||
if e.Type != "test_d" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_d", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 789 {
|
||||
t.Fatalf("Expected context value %v, got %v", 789, v)
|
||||
}
|
||||
|
||||
if e.Model.PK() != testCollections[3].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[3].Id, e.Model.PK(), 3)
|
||||
}
|
||||
}
|
||||
|
||||
expectedBeforeCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
|
||||
if e.Type != "test_a" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_a", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != nil {
|
||||
t.Fatalf("Expected context value %v, got %v", nil, v)
|
||||
}
|
||||
|
||||
if e.Collection.Id != testCollections[0].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[0].Id, e.Collection.Id, 0)
|
||||
}
|
||||
}
|
||||
|
||||
expectedAfterCollectionEventHandlerChecks := func(t *testing.T, e *core.CollectionEvent) {
|
||||
if e.Type != "test_c" {
|
||||
t.Fatalf("Expected type %q, got %q", "test_c", e.Type)
|
||||
}
|
||||
|
||||
if v := e.Context.Value("test"); v != 456 {
|
||||
t.Fatalf("Expected context value %v, got %v", 456, v)
|
||||
}
|
||||
|
||||
if e.Collection.Id != testCollections[2].Id {
|
||||
t.Fatalf("Expected collection with id %q, got %q (%d)", testCollections[2].Id, e.Collection.Id, 2)
|
||||
}
|
||||
}
|
||||
|
||||
modelEventFinalizer := func(e *core.ModelEvent) error {
|
||||
modelEventFinalizerChange(e)
|
||||
return nil
|
||||
}
|
||||
|
||||
modelErrorEventFinalizer := func(e *core.ModelErrorEvent) error {
|
||||
modelEventFinalizerChange(&e.ModelEvent)
|
||||
e.Error = errors.New("error_c")
|
||||
return nil
|
||||
}
|
||||
|
||||
modelEventHandler := &hook.Handler[*core.ModelEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
t.Run("before model", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
modelErrorEventHandler := &hook.Handler[*core.ModelErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
t.Run("before model error", func(t *testing.T) {
|
||||
expectedBeforeModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_a", v)
|
||||
}
|
||||
})
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after model error", func(t *testing.T) {
|
||||
expectedAfterModelEventHandlerChecks(t, &e.ModelEvent)
|
||||
if v := e.Error.Error(); v != "error_d" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_d", v)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
recordEventHandler := &hook.Handler[*core.CollectionEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
t.Run("before collection", func(t *testing.T) {
|
||||
expectedBeforeCollectionEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeCollectionEventBefore(e)
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after collection", func(t *testing.T) {
|
||||
expectedAfterCollectionEventHandlerChecks(t, e)
|
||||
})
|
||||
|
||||
changeCollectionEventAfter(e)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
collectionErrorEventHandler := &hook.Handler[*core.CollectionErrorEvent]{
|
||||
Priority: -999,
|
||||
Func: func(e *core.CollectionErrorEvent) error {
|
||||
t.Run("before collection error", func(t *testing.T) {
|
||||
expectedBeforeCollectionEventHandlerChecks(t, &e.CollectionEvent)
|
||||
if v := e.Error.Error(); v != "error_a" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeCollectionEventBefore(&e.CollectionEvent)
|
||||
e.Error = errors.New("error_b")
|
||||
|
||||
_ = e.Next()
|
||||
|
||||
t.Run("after collection error", func(t *testing.T) {
|
||||
expectedAfterCollectionEventHandlerChecks(t, &e.CollectionEvent)
|
||||
if v := e.Error.Error(); v != "error_c" {
|
||||
t.Fatalf("Expected error %q, got %q", "error_c", v)
|
||||
}
|
||||
})
|
||||
|
||||
changeCollectionEventAfter(&e.CollectionEvent)
|
||||
e.Error = errors.New("error_d")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// OnModelValidate
|
||||
app.OnCollectionValidate().Bind(recordEventHandler)
|
||||
app.OnModelValidate().Bind(modelEventHandler)
|
||||
app.OnModelValidate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreate
|
||||
app.OnCollectionCreate().Bind(recordEventHandler)
|
||||
app.OnModelCreate().Bind(modelEventHandler)
|
||||
app.OnModelCreate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelCreateExecute
|
||||
app.OnCollectionCreateExecute().Bind(recordEventHandler)
|
||||
app.OnModelCreateExecute().Bind(modelEventHandler)
|
||||
app.OnModelCreateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateSuccess
|
||||
app.OnCollectionAfterCreateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterCreateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterCreateError
|
||||
app.OnCollectionAfterCreateError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterCreateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelUpdate
|
||||
app.OnCollectionUpdate().Bind(recordEventHandler)
|
||||
app.OnModelUpdate().Bind(modelEventHandler)
|
||||
app.OnModelUpdate().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelUpdateExecute
|
||||
app.OnCollectionUpdateExecute().Bind(recordEventHandler)
|
||||
app.OnModelUpdateExecute().Bind(modelEventHandler)
|
||||
app.OnModelUpdateExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateSuccess
|
||||
app.OnCollectionAfterUpdateSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterUpdateSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterUpdateError
|
||||
app.OnCollectionAfterUpdateError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterUpdateError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
|
||||
// OnModelDelete
|
||||
app.OnCollectionDelete().Bind(recordEventHandler)
|
||||
app.OnModelDelete().Bind(modelEventHandler)
|
||||
app.OnModelDelete().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelDeleteExecute
|
||||
app.OnCollectionDeleteExecute().Bind(recordEventHandler)
|
||||
app.OnModelDeleteExecute().Bind(modelEventHandler)
|
||||
app.OnModelDeleteExecute().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteSuccess
|
||||
app.OnCollectionAfterDeleteSuccess().Bind(recordEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Bind(modelEventHandler)
|
||||
app.OnModelAfterDeleteSuccess().Trigger(createModelEvent(), modelEventFinalizer)
|
||||
|
||||
// OnModelAfterDeleteError
|
||||
app.OnCollectionAfterDeleteError().Bind(collectionErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Bind(modelErrorEventHandler)
|
||||
app.OnModelAfterDeleteError().Trigger(createModelErrorEvent(), modelErrorEventFinalizer)
|
||||
}
|
||||
|
||||
func TestCollectionSaveModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1258,60 +1576,62 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
|
||||
viewName := "test_wrapping"
|
||||
|
||||
// note: some of the queries use "limit 0" because the tested field value could be empty
|
||||
// which will trigger the extra sample records validation that are not important for this test
|
||||
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 id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select 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 - text field",
|
||||
"select text as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"select rel_one as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"select select_many as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"select email as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"select datetime as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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)",
|
||||
"select url as id, bool from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1 limit 0)",
|
||||
},
|
||||
{
|
||||
"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))",
|
||||
"select bool as id, text as txt, url from demo1 limit 0",
|
||||
"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 limit 0))",
|
||||
},
|
||||
{
|
||||
"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))",
|
||||
"select text as txt, url, bool as id from demo1 limit 0",
|
||||
"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 limit 0))",
|
||||
},
|
||||
{
|
||||
"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))",
|
||||
"select json as id, text, url from demo1 limit 0",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT CAST(`id` as TEXT) `id`,`text`,`url` FROM (select json as id, text, url from demo1 limit 0))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
@@ -1345,7 +1665,7 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
|
||||
var sql string
|
||||
|
||||
rowErr := app.DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
rowErr := app.ConcurrentDB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
Bind(dbx.Params{"name": viewName}).
|
||||
Row(&sql)
|
||||
if rowErr != nil {
|
||||
|
||||
@@ -41,6 +41,24 @@ func TestCollectionViewOptionsValidate(t *testing.T) {
|
||||
},
|
||||
expectedErrors: []string{"fields", "viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query but empty sample id",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewViewCollection("new_auth")
|
||||
c.ViewQuery = "select '' as id"
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query but duplicated sample id",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
c := core.NewViewCollection("new_auth")
|
||||
c.ViewQuery = "(select 'a' as id union all select 'a' as id union all select 'c' as id)"
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"viewQuery"},
|
||||
},
|
||||
{
|
||||
name: "view with valid query",
|
||||
collection: func(app core.App) (*core.Collection, error) {
|
||||
|
||||
@@ -38,7 +38,7 @@ func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection
|
||||
q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
|
||||
}
|
||||
|
||||
err := q.OrderBy("created ASC").All(&collections)
|
||||
err := q.OrderBy("rowid ASC").All(&collections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bo
|
||||
// cascade and file delete actions.
|
||||
func (app *BaseApp) TruncateCollection(collection *Collection) error {
|
||||
if collection.IsView() {
|
||||
return errors.New("view collections cannot be truncated since they don't store their own records.")
|
||||
return errors.New("view collections cannot be truncated since they don't store their own records")
|
||||
}
|
||||
|
||||
return app.RunInTransaction(func(txApp App) error {
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
totalQueries := 0
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func TestFindCachedCollectionReferences(t *testing.T) {
|
||||
}
|
||||
|
||||
totalQueries := 0
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ func (app *BaseApp) SyncRecordTableSchema(newCollection *Collection, oldCollecti
|
||||
|
||||
// run optimize per the SQLite recommendations
|
||||
// (https://www.sqlite.org/pragma.html#pragma_optimize)
|
||||
_, optimizeErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
_, optimizeErr := app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
|
||||
if optimizeErr != nil {
|
||||
app.Logger().Warn("Failed to run PRAGMA optimize after record table sync", slog.String("error", optimizeErr.Error()))
|
||||
}
|
||||
@@ -310,7 +310,8 @@ func dropCollectionIndexes(app App, collection *Collection) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := app.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
|
||||
_, err := txApp.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ func onCollectionValidate(e *CollectionEvent) error {
|
||||
original,
|
||||
)
|
||||
|
||||
return validator.run()
|
||||
if err := validator.run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
|
||||
@@ -83,7 +87,7 @@ func (validator *collectionValidator) run() error {
|
||||
validator.original.IsNew(),
|
||||
validation.Length(1, 100),
|
||||
validation.Match(DefaultIdRegex),
|
||||
validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())),
|
||||
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
|
||||
).Else(
|
||||
validation.By(validators.Equal(validator.original.Id)),
|
||||
),
|
||||
@@ -310,11 +314,15 @@ func (cv *collectionValidator) checkViewQuery(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if _, err := cv.app.CreateViewFields(v); err != nil {
|
||||
return validation.NewError(
|
||||
"validation_invalid_view_query",
|
||||
fmt.Sprintf("Invalid query - %s", err.Error()),
|
||||
)
|
||||
_, err := cv.app.DryRunView(v, 10)
|
||||
if err != nil {
|
||||
rawErr := err.Error()
|
||||
if len(rawErr) > 500 {
|
||||
// restrict just as an extra precaution
|
||||
rawErr = rawErr[:500]
|
||||
}
|
||||
|
||||
return validation.NewError("validation_invalid_view_query", "Invalid query - "+rawErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -485,7 +493,7 @@ func (validator *collectionValidator) checkRule(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
r := NewRecordFieldResolver(validator.app, validator.new, nil, true)
|
||||
r := NewRecordFieldResolver(validator.app, validator.new, &RequestInfo{}, true)
|
||||
_, err := search.FilterData(vStr).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error())
|
||||
@@ -554,7 +562,7 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
|
||||
// ensure that the index name is not used in another collection
|
||||
var usedTblName string
|
||||
_ = cv.app.DB().Select("tbl_name").
|
||||
_ = cv.app.ConcurrentDB().Select("tbl_name").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.HashExp{"type": "index"}).
|
||||
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
|
||||
|
||||
@@ -319,8 +319,8 @@ func TestCollectionValidate(t *testing.T) {
|
||||
c.ListRule = types.Pointer("!invalid")
|
||||
c.ViewRule = types.Pointer("missing = 123")
|
||||
c.CreateRule = types.Pointer("id = 123 && missing = 456")
|
||||
c.UpdateRule = types.Pointer("(id = 123")
|
||||
c.DeleteRule = types.Pointer("missing = 123")
|
||||
c.UpdateRule = types.Pointer("@request.body.missing:changed = false")
|
||||
c.DeleteRule = types.Pointer("(id=123")
|
||||
return c, nil
|
||||
},
|
||||
expectedErrors: []string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
@@ -333,7 +333,7 @@ func TestCollectionValidate(t *testing.T) {
|
||||
c.ListRule = types.Pointer("")
|
||||
c.ViewRule = types.Pointer("f1 = 123")
|
||||
c.CreateRule = types.Pointer("id = 123 && f1 = 456")
|
||||
c.UpdateRule = types.Pointer("(id = 123)")
|
||||
c.UpdateRule = types.Pointer("(id = 123 && @request.body.f1:changed = false)")
|
||||
c.DeleteRule = types.Pointer("f1 = 123")
|
||||
return c, nil
|
||||
},
|
||||
|
||||
+8
-8
@@ -62,16 +62,16 @@ func crc32Checksum(str string) string {
|
||||
return strconv.FormatInt(int64(crc32.ChecksumIEEE([]byte(str))), 10)
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select app.DB() query with preset
|
||||
// ModelQuery creates a new preconfigured select data.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (app *BaseApp) ModelQuery(m Model) *dbx.SelectQuery {
|
||||
return app.modelQuery(app.DB(), m)
|
||||
return app.modelQuery(app.ConcurrentDB(), m)
|
||||
}
|
||||
|
||||
// AuxModelQuery creates a new preconfigured select app.AuxDB() query with preset
|
||||
// AuxModelQuery creates a new preconfigured select auxiliary.db query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (app *BaseApp) AuxModelQuery(m Model) *dbx.SelectQuery {
|
||||
return app.modelQuery(app.AuxDB(), m)
|
||||
return app.modelQuery(app.AuxConcurrentDB(), m)
|
||||
}
|
||||
|
||||
func (app *BaseApp) modelQuery(db dbx.Builder, m Model) *dbx.SelectQuery {
|
||||
@@ -151,7 +151,7 @@ func (app *BaseApp) delete(ctx context.Context, model Model, isForAuxDB bool) er
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (app *BaseApp) create(ctx context.Context, model Model, withValidations boo
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -426,7 +426,7 @@ func (app *BaseApp) update(ctx context.Context, model Model, withValidations boo
|
||||
|
||||
if app.txInfo != nil {
|
||||
// execute later after the transaction has completed
|
||||
app.txInfo.onAfterFunc(func(txErr error) error {
|
||||
app.txInfo.OnComplete(func(txErr error) error {
|
||||
if app.txInfo != nil && app.txInfo.parent != nil {
|
||||
event.App = app.txInfo.parent
|
||||
}
|
||||
@@ -484,7 +484,7 @@ func validateRecordId(app App, collectionNameOrId string) validation.RuleFunc {
|
||||
|
||||
var exists int
|
||||
|
||||
rowErr := app.DB().Select("(1)").
|
||||
rowErr := app.ConcurrentDB().Select("(1)").
|
||||
From(collection.Name).
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ dbx.Builder = (*dualDBBuilder)(nil)
|
||||
|
||||
// note: expects both builder to use the same driver
|
||||
type dualDBBuilder struct {
|
||||
concurrentDB dbx.Builder
|
||||
nonconcurrentDB dbx.Builder
|
||||
}
|
||||
|
||||
// Select implements the [dbx.Builder.Select] interface method.
|
||||
func (b *dualDBBuilder) Select(cols ...string) *dbx.SelectQuery {
|
||||
return b.concurrentDB.Select(cols...)
|
||||
}
|
||||
|
||||
// Model implements the [dbx.Builder.Model] interface method.
|
||||
func (b *dualDBBuilder) Model(data interface{}) *dbx.ModelQuery {
|
||||
return b.nonconcurrentDB.Model(data)
|
||||
}
|
||||
|
||||
// GeneratePlaceholder implements the [dbx.Builder.GeneratePlaceholder] interface method.
|
||||
func (b *dualDBBuilder) GeneratePlaceholder(i int) string {
|
||||
return b.concurrentDB.GeneratePlaceholder(i)
|
||||
}
|
||||
|
||||
// Quote implements the [dbx.Builder.Quote] interface method.
|
||||
func (b *dualDBBuilder) Quote(str string) string {
|
||||
return b.concurrentDB.Quote(str)
|
||||
}
|
||||
|
||||
// QuoteSimpleTableName implements the [dbx.Builder.QuoteSimpleTableName] interface method.
|
||||
func (b *dualDBBuilder) QuoteSimpleTableName(table string) string {
|
||||
return b.concurrentDB.QuoteSimpleTableName(table)
|
||||
}
|
||||
|
||||
// QuoteSimpleColumnName implements the [dbx.Builder.QuoteSimpleColumnName] interface method.
|
||||
func (b *dualDBBuilder) QuoteSimpleColumnName(col string) string {
|
||||
return b.concurrentDB.QuoteSimpleColumnName(col)
|
||||
}
|
||||
|
||||
// QueryBuilder implements the [dbx.Builder.QueryBuilder] interface method.
|
||||
func (b *dualDBBuilder) QueryBuilder() dbx.QueryBuilder {
|
||||
return b.concurrentDB.QueryBuilder()
|
||||
}
|
||||
|
||||
// Insert implements the [dbx.Builder.Insert] interface method.
|
||||
func (b *dualDBBuilder) Insert(table string, cols dbx.Params) *dbx.Query {
|
||||
return b.nonconcurrentDB.Insert(table, cols)
|
||||
}
|
||||
|
||||
// Upsert implements the [dbx.Builder.Upsert] interface method.
|
||||
func (b *dualDBBuilder) Upsert(table string, cols dbx.Params, constraints ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.Upsert(table, cols, constraints...)
|
||||
}
|
||||
|
||||
// Update implements the [dbx.Builder.Update] interface method.
|
||||
func (b *dualDBBuilder) Update(table string, cols dbx.Params, where dbx.Expression) *dbx.Query {
|
||||
return b.nonconcurrentDB.Update(table, cols, where)
|
||||
}
|
||||
|
||||
// Delete implements the [dbx.Builder.Delete] interface method.
|
||||
func (b *dualDBBuilder) Delete(table string, where dbx.Expression) *dbx.Query {
|
||||
return b.nonconcurrentDB.Delete(table, where)
|
||||
}
|
||||
|
||||
// CreateTable implements the [dbx.Builder.CreateTable] interface method.
|
||||
func (b *dualDBBuilder) CreateTable(table string, cols map[string]string, options ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateTable(table, cols, options...)
|
||||
}
|
||||
|
||||
// RenameTable implements the [dbx.Builder.RenameTable] interface method.
|
||||
func (b *dualDBBuilder) RenameTable(oldName, newName string) *dbx.Query {
|
||||
return b.nonconcurrentDB.RenameTable(oldName, newName)
|
||||
}
|
||||
|
||||
// DropTable implements the [dbx.Builder.DropTable] interface method.
|
||||
func (b *dualDBBuilder) DropTable(table string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropTable(table)
|
||||
}
|
||||
|
||||
// TruncateTable implements the [dbx.Builder.TruncateTable] interface method.
|
||||
func (b *dualDBBuilder) TruncateTable(table string) *dbx.Query {
|
||||
return b.nonconcurrentDB.TruncateTable(table)
|
||||
}
|
||||
|
||||
// AddColumn implements the [dbx.Builder.AddColumn] interface method.
|
||||
func (b *dualDBBuilder) AddColumn(table, col, typ string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddColumn(table, col, typ)
|
||||
}
|
||||
|
||||
// DropColumn implements the [dbx.Builder.DropColumn] interface method.
|
||||
func (b *dualDBBuilder) DropColumn(table, col string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropColumn(table, col)
|
||||
}
|
||||
|
||||
// RenameColumn implements the [dbx.Builder.RenameColumn] interface method.
|
||||
func (b *dualDBBuilder) RenameColumn(table, oldName, newName string) *dbx.Query {
|
||||
return b.nonconcurrentDB.RenameColumn(table, oldName, newName)
|
||||
}
|
||||
|
||||
// AlterColumn implements the [dbx.Builder.AlterColumn] interface method.
|
||||
func (b *dualDBBuilder) AlterColumn(table, col, typ string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AlterColumn(table, col, typ)
|
||||
}
|
||||
|
||||
// AddPrimaryKey implements the [dbx.Builder.AddPrimaryKey] interface method.
|
||||
func (b *dualDBBuilder) AddPrimaryKey(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddPrimaryKey(table, name, cols...)
|
||||
}
|
||||
|
||||
// DropPrimaryKey implements the [dbx.Builder.DropPrimaryKey] interface method.
|
||||
func (b *dualDBBuilder) DropPrimaryKey(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropPrimaryKey(table, name)
|
||||
}
|
||||
|
||||
// AddForeignKey implements the [dbx.Builder.AddForeignKey] interface method.
|
||||
func (b *dualDBBuilder) AddForeignKey(table, name string, cols, refCols []string, refTable string, options ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.AddForeignKey(table, name, cols, refCols, refTable, options...)
|
||||
}
|
||||
|
||||
// DropForeignKey implements the [dbx.Builder.DropForeignKey] interface method.
|
||||
func (b *dualDBBuilder) DropForeignKey(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropForeignKey(table, name)
|
||||
}
|
||||
|
||||
// CreateIndex implements the [dbx.Builder.CreateIndex] interface method.
|
||||
func (b *dualDBBuilder) CreateIndex(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateIndex(table, name, cols...)
|
||||
}
|
||||
|
||||
// CreateUniqueIndex implements the [dbx.Builder.CreateUniqueIndex] interface method.
|
||||
func (b *dualDBBuilder) CreateUniqueIndex(table, name string, cols ...string) *dbx.Query {
|
||||
return b.nonconcurrentDB.CreateUniqueIndex(table, name, cols...)
|
||||
}
|
||||
|
||||
// DropIndex implements the [dbx.Builder.DropIndex] interface method.
|
||||
func (b *dualDBBuilder) DropIndex(table, name string) *dbx.Query {
|
||||
return b.nonconcurrentDB.DropIndex(table, name)
|
||||
}
|
||||
|
||||
// NewQuery implements the [dbx.Builder.NewQuery] interface method by
|
||||
// routing the SELECT queries to the concurrent builder instance.
|
||||
func (b *dualDBBuilder) NewQuery(str string) *dbx.Query {
|
||||
// note: technically INSERT/UPDATE/DELETE could also have CTE but since
|
||||
// it is rare for now this scase is ignored to avoid unnecessary complicating the checks
|
||||
trimmed := trimLeftSpaces(str)
|
||||
if hasPrefixFold(trimmed, "SELECT") || hasPrefixFold(trimmed, "WITH") {
|
||||
return b.concurrentDB.NewQuery(str)
|
||||
}
|
||||
|
||||
return b.nonconcurrentDB.NewQuery(str)
|
||||
}
|
||||
|
||||
var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}
|
||||
|
||||
// note: similar to strings.Space() but without the right trim because it is not needed in our case
|
||||
func trimLeftSpaces(str string) string {
|
||||
start := 0
|
||||
for ; start < len(str); start++ {
|
||||
c := str[start]
|
||||
if c >= utf8.RuneSelf {
|
||||
return strings.TrimLeftFunc(str[start:], unicode.IsSpace)
|
||||
}
|
||||
if asciiSpace[c] == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return str[start:]
|
||||
}
|
||||
|
||||
// note: the prefix is expected to be ASCII
|
||||
func hasPrefixFold(str, prefix string) bool {
|
||||
if len(str) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(str[:len(prefix)], prefix)
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ func DefaultDBConnect(dbPath string) (*dbx.DB, error) {
|
||||
// Note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection.
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
|
||||
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
|
||||
+15
-17
@@ -11,7 +11,7 @@ import (
|
||||
func (app *BaseApp) TableColumns(tableName string) ([]string, error) {
|
||||
columns := []string{}
|
||||
|
||||
err := app.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
err := app.ConcurrentDB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
Bind(dbx.Params{"tableName": tableName}).
|
||||
Column(&columns)
|
||||
|
||||
@@ -34,7 +34,7 @@ type TableInfoRow struct {
|
||||
func (app *BaseApp) TableInfo(tableName string) ([]*TableInfoRow, error) {
|
||||
info := []*TableInfoRow{}
|
||||
|
||||
err := app.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
err := app.ConcurrentDB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
Bind(dbx.Params{"tableName": tableName}).
|
||||
All(&info)
|
||||
if err != nil {
|
||||
@@ -59,7 +59,7 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
Sql string
|
||||
}{}
|
||||
|
||||
err := app.DB().Select("name", "sql").
|
||||
err := app.ConcurrentDB().Select("name", "sql").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.NewExp("sql is not null")).
|
||||
AndWhere(dbx.HashExp{
|
||||
@@ -85,26 +85,26 @@ func (app *BaseApp) TableIndexes(tableName string) (map[string]string, error) {
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
//
|
||||
// NB! Be aware that this method is vulnerable to SQL injection and the
|
||||
// "tableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(tableName string) error {
|
||||
_, err := app.DB().NewQuery(fmt.Sprintf(
|
||||
// "dangerousTableName" argument must come only from trusted input!
|
||||
func (app *BaseApp) DeleteTable(dangerousTableName string) error {
|
||||
_, err := app.NonconcurrentDB().NewQuery(fmt.Sprintf(
|
||||
"DROP TABLE IF EXISTS {{%s}}",
|
||||
tableName,
|
||||
dangerousTableName,
|
||||
)).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// HasTable checks if a table (or view) with the provided name exists (case insensitive).
|
||||
// in the current app.DB() instance.
|
||||
// in the data.db.
|
||||
func (app *BaseApp) HasTable(tableName string) bool {
|
||||
return app.hasTable(app.DB(), tableName)
|
||||
return app.hasTable(app.ConcurrentDB(), tableName)
|
||||
}
|
||||
|
||||
// AuxHasTable checks if a table (or view) with the provided name exists (case insensitive)
|
||||
// in the current app.AuxDB() instance.
|
||||
// in the auixiliary.db.
|
||||
func (app *BaseApp) AuxHasTable(tableName string) bool {
|
||||
return app.hasTable(app.AuxDB(), tableName)
|
||||
return app.hasTable(app.AuxConcurrentDB(), tableName)
|
||||
}
|
||||
|
||||
func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
@@ -120,16 +120,14 @@ func (app *BaseApp) hasTable(db dbx.Builder, tableName string) bool {
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
// Vacuum executes VACUUM on the current app.DB() instance
|
||||
// in order to reclaim unused data db disk space.
|
||||
// Vacuum executes VACUUM on the data.db in order to reclaim unused data db disk space.
|
||||
func (app *BaseApp) Vacuum() error {
|
||||
return app.vacuum(app.DB())
|
||||
return app.vacuum(app.NonconcurrentDB())
|
||||
}
|
||||
|
||||
// AuxVacuum executes VACUUM on the current app.AuxDB() instance
|
||||
// in order to reclaim unused auxiliary db disk space.
|
||||
// AuxVacuum executes VACUUM on the auxiliary.db in order to reclaim unused auxiliary db disk space.
|
||||
func (app *BaseApp) AuxVacuum() error {
|
||||
return app.vacuum(app.AuxDB())
|
||||
return app.vacuum(app.AuxNonconcurrentDB())
|
||||
}
|
||||
|
||||
func (app *BaseApp) vacuum(db dbx.Builder) error {
|
||||
|
||||
@@ -202,10 +202,10 @@ func TestVacuum(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.DB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.DB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
app.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
@@ -229,10 +229,10 @@ func TestAuxVacuum(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
calledQueries := []string{}
|
||||
app.AuxDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
app.AuxDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
app.AuxNonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
calledQueries = append(calledQueries, sql)
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -38,12 +38,12 @@ func TestModelQuery(t *testing.T) {
|
||||
modelsQuery := app.ModelQuery(&core.Collection{})
|
||||
logsModelQuery := app.AuxModelQuery(&core.Collection{})
|
||||
|
||||
if app.DB() == modelsQuery.Info().Builder {
|
||||
t.Fatalf("ModelQuery() is not using app.DB()")
|
||||
if app.ConcurrentDB() == modelsQuery.Info().Builder {
|
||||
t.Fatalf("ModelQuery() is not using app.ConcurrentDB()")
|
||||
}
|
||||
|
||||
if app.AuxDB() == logsModelQuery.Info().Builder {
|
||||
t.Fatalf("AuxModelQuery() is not using app.AuxDB()")
|
||||
if app.AuxConcurrentDB() == logsModelQuery.Info().Builder {
|
||||
t.Fatalf("AuxModelQuery() is not using app.AuxConcurrentDB()")
|
||||
}
|
||||
|
||||
expectedSQL := "SELECT {{_collections}}.* FROM `_collections`"
|
||||
|
||||
+12
-5
@@ -60,7 +60,7 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
|
||||
clone.nonconcurrentDB = tx
|
||||
}
|
||||
|
||||
clone.txInfo = &txAppInfo{
|
||||
clone.txInfo = &TxAppInfo{
|
||||
parent: app,
|
||||
isForAuxDB: isForAuxDB,
|
||||
}
|
||||
@@ -68,22 +68,29 @@ func (app *BaseApp) createTxApp(tx *dbx.Tx, isForAuxDB bool) *BaseApp {
|
||||
return &clone
|
||||
}
|
||||
|
||||
type txAppInfo struct {
|
||||
// TxAppInfo represents an active transaction context associated to an existing app instance.
|
||||
type TxAppInfo struct {
|
||||
parent *BaseApp
|
||||
afterFuncs []func(txErr error) error
|
||||
mu sync.Mutex
|
||||
isForAuxDB bool
|
||||
}
|
||||
|
||||
func (a *txAppInfo) onAfterFunc(fn func(txErr error) error) {
|
||||
// OnComplete registers the provided callback that will be invoked
|
||||
// once the related transaction ends (either completes successfully or rollbacked with an error).
|
||||
//
|
||||
// The callback receives the transaction error (if any) as its argument.
|
||||
// Any additional errors returned by the OnComplete callbacks will be
|
||||
// joined together with txErr when returning the final transaction result.
|
||||
func (a *TxAppInfo) OnComplete(fn func(txErr error) error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.afterFuncs = append(a.afterFuncs, fn)
|
||||
}
|
||||
|
||||
// note: can be called only once because txAppInfo is cleared
|
||||
func (a *txAppInfo) runAfterFuncs(txErr error) error {
|
||||
// note: can be called only once because TxAppInfo is cleared
|
||||
func (a *TxAppInfo) runAfterFuncs(txErr error) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
|
||||
@@ -154,8 +154,10 @@ const (
|
||||
RequestInfoContextExpand = "expand"
|
||||
RequestInfoContextRealtime = "realtime"
|
||||
RequestInfoContextProtectedFile = "protectedFile"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextBatch = "batch"
|
||||
RequestInfoContextOAuth2 = "oauth2"
|
||||
RequestInfoContextOTP = "otp"
|
||||
RequestInfoContextPasswordAuth = "password"
|
||||
)
|
||||
|
||||
// RequestInfo defines a HTTP request data struct, usually used
|
||||
|
||||
+34
-2
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -56,6 +58,9 @@ type baseCollectionEventData struct {
|
||||
Collection *Collection
|
||||
}
|
||||
|
||||
// @todo consider storing the original collection name and use that as a tag
|
||||
// to avoid the ambiguity when the collection is being modified (#7613);
|
||||
// for new collection also maybe return empty tags?
|
||||
func (e *baseCollectionEventData) Tags() []string {
|
||||
if e.Collection == nil {
|
||||
return nil
|
||||
@@ -104,6 +109,11 @@ type ServeEvent struct {
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
|
||||
// Listener allow specifying a custom network listener.
|
||||
//
|
||||
// Leave it nil to use the default net.Listen("tcp", e.Server.Addr).
|
||||
Listener net.Listener
|
||||
|
||||
// InstallerFunc is the "installer" function that is called after
|
||||
// successful server tcp bind but only if there is no explicit
|
||||
// superuser record created yet.
|
||||
@@ -119,6 +129,21 @@ type ServeEvent struct {
|
||||
//
|
||||
// Set it to nil if you want to skip the installer.
|
||||
InstallerFunc func(app App, systemSuperuser *Record, baseURL string) error
|
||||
|
||||
// @todo experimental
|
||||
//
|
||||
// UIExtensions is a list with the superuser UI extensions.
|
||||
UIExtensions []UIExtension
|
||||
}
|
||||
|
||||
type UIExtension struct {
|
||||
// Name is the name of the extension.
|
||||
// It is also used as path segment for the registered public extension endpoint
|
||||
// (e.g. /_/extensions/{name}/*)
|
||||
Name string
|
||||
|
||||
// FS is the extension file system.
|
||||
FS fs.FS
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -277,7 +302,7 @@ func syncModelErrorEventWithRecordErrorEvent(me *ModelErrorEvent, re *RecordErro
|
||||
|
||||
func syncRecordErrorEventWithModelErrorEvent(re *RecordErrorEvent, me *ModelErrorEvent) {
|
||||
syncRecordEventWithModelEvent(&re.RecordEvent, &me.ModelEvent)
|
||||
me.Error = re.Error
|
||||
re.Error = me.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -354,7 +379,7 @@ func syncModelErrorEventWithCollectionErrorEvent(me *ModelErrorEvent, ce *Collec
|
||||
|
||||
func syncCollectionErrorEventWithModelErrorEvent(ce *CollectionErrorEvent, me *ModelErrorEvent) {
|
||||
syncCollectionEventWithModelEvent(&ce.CollectionEvent, &me.ModelEvent)
|
||||
me.Error = ce.Error
|
||||
ce.Error = me.Error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -378,6 +403,13 @@ type FileDownloadRequestEvent struct {
|
||||
FileField *FileField
|
||||
ServedPath string
|
||||
ServedName string
|
||||
|
||||
// ThumbError indicates the a thumb wasn't able to be generated
|
||||
// (e.g. because it didn't satisfy the support image formats or it timed out).
|
||||
//
|
||||
// Note that PocketBase fallbacks to the original file in case of a thumb error,
|
||||
// but developers can check the field and provide their own custom thumb generation if necessary.
|
||||
ThumbError error
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestFindFirstExternalAuthByExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.DB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%d_%v", i, s.expr.Build(app.ConcurrentDB().(*dbx.DB), dbx.Params{})), func(t *testing.T) {
|
||||
result, err := app.FindFirstExternalAuthByExpr(s.expr)
|
||||
|
||||
hasErr := err != nil
|
||||
|
||||
@@ -184,6 +184,26 @@ type RecordInterceptor interface {
|
||||
) error
|
||||
}
|
||||
|
||||
// DefaultFieldHelpValidationRule performs base validation on a field's "help" value.
|
||||
func DefaultFieldHelpValidationRule(value any) error {
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
rules := []validation.Rule{
|
||||
validation.Length(1, 300),
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if err := r.Validate(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultFieldIdValidationRule performs base validation on a field id value.
|
||||
func DefaultFieldIdValidationRule(value any) error {
|
||||
v, ok := value.(string)
|
||||
|
||||
@@ -46,12 +46,12 @@ type AutodateField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
|
||||
// OnCreate auto sets the current datetime as field value on record create.
|
||||
OnCreate bool `form:"onCreate" json:"onCreate"`
|
||||
|
||||
|
||||
+6
-1
@@ -36,11 +36,15 @@ type BoolField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Required will require the field value to be always "true".
|
||||
Required bool `form:"required" json:"required"`
|
||||
@@ -120,5 +124,6 @@ func (f *BoolField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,4 +147,5 @@ func TestBoolFieldValidateValue(t *testing.T) {
|
||||
func TestBoolFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeBool)
|
||||
testDefaultFieldHelpValidation[core.BoolField](t)
|
||||
}
|
||||
|
||||
+6
-1
@@ -36,11 +36,15 @@ type DateField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Min specifies the min allowed field value.
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *DateField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.Max, validation.By(f.checkRange(f.Min, f.Max))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestDateFieldValidateValue(t *testing.T) {
|
||||
func TestDateFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeDate)
|
||||
testDefaultFieldHelpValidation[core.DateField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -41,11 +41,15 @@ type EditorField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
@@ -148,6 +152,7 @@ func (f *EditorField) ValidateSettings(ctx context.Context, app App, collection
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ func TestEditorFieldValidateValue(t *testing.T) {
|
||||
func TestEditorFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEditor)
|
||||
testDefaultFieldHelpValidation[core.EditorField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+6
-1
@@ -39,11 +39,15 @@ type EmailField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// ExceptDomains will require the email domain to NOT be included in the listed ones.
|
||||
//
|
||||
@@ -155,6 +159,7 @@ func (f *EmailField) ValidateSettings(ctx context.Context, app App, collection *
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(
|
||||
&f.ExceptDomains,
|
||||
validation.When(len(f.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
|
||||
@@ -182,6 +182,7 @@ func TestEmailFieldValidateValue(t *testing.T) {
|
||||
func TestEmailFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeEmail)
|
||||
testDefaultFieldHelpValidation[core.EmailField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
+8
-5
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
@@ -48,7 +47,7 @@ var (
|
||||
// FileField defines "file" type field for managing record file(s).
|
||||
//
|
||||
// Only the file name is stored as part of the record value.
|
||||
// New files (aka. files to upload) are expected to be of *filesytem.File.
|
||||
// New files (aka. files to upload) are expected to be of *filesystem.File.
|
||||
//
|
||||
// If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
|
||||
//
|
||||
@@ -89,11 +88,15 @@ type FileField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
|
||||
//
|
||||
@@ -224,6 +227,7 @@ func (f *FileField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSelect, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
validation.Field(&f.Thumbs, validation.Each(
|
||||
@@ -357,8 +361,7 @@ func (f *FileField) Intercept(
|
||||
return nil
|
||||
case InterceptorActionAfterCreateError, InterceptorActionAfterUpdateError:
|
||||
// when in transaction we assume that the error was handled by afterRecordExecuteFailure
|
||||
_, insideTransaction := app.DB().(*dbx.Tx)
|
||||
if insideTransaction {
|
||||
if app.IsTransactional() {
|
||||
return actionFunc()
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,7 @@ func TestFileFieldValidateValue(t *testing.T) {
|
||||
func TestFileFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeFile)
|
||||
testDefaultFieldHelpValidation[core.FileField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Fields[FieldTypeGeoPoint] = func() Field {
|
||||
return &GeoPointField{}
|
||||
}
|
||||
}
|
||||
|
||||
const FieldTypeGeoPoint = "geoPoint"
|
||||
|
||||
var (
|
||||
_ Field = (*GeoPointField)(nil)
|
||||
)
|
||||
|
||||
// GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
|
||||
//
|
||||
// You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
|
||||
// The stored value is always converted to [types.GeoPoint].
|
||||
// Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
|
||||
//
|
||||
// Examples of updating a record's GeoPointField value programmatically:
|
||||
//
|
||||
// record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
|
||||
// record.Set("location", map[string]any{"lat":123, "lon":456})
|
||||
// record.Set("location", []byte(`{"lat":123, "lon":456}`)
|
||||
type GeoPointField struct {
|
||||
// Name (required) is the unique name of the field.
|
||||
Name string `form:"name" json:"name"`
|
||||
|
||||
// Id is the unique stable field identifier.
|
||||
//
|
||||
// It is automatically generated from the name when adding to a collection FieldsList.
|
||||
Id string `form:"id" json:"id"`
|
||||
|
||||
// System prevents the renaming and removal of the field.
|
||||
System bool `form:"system" json:"system"`
|
||||
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// Required will require the field coordinates to be non-zero (aka. not "Null Island").
|
||||
Required bool `form:"required" json:"required"`
|
||||
}
|
||||
|
||||
// Type implements [Field.Type] interface method.
|
||||
func (f *GeoPointField) Type() string {
|
||||
return FieldTypeGeoPoint
|
||||
}
|
||||
|
||||
// GetId implements [Field.GetId] interface method.
|
||||
func (f *GeoPointField) GetId() string {
|
||||
return f.Id
|
||||
}
|
||||
|
||||
// SetId implements [Field.SetId] interface method.
|
||||
func (f *GeoPointField) SetId(id string) {
|
||||
f.Id = id
|
||||
}
|
||||
|
||||
// GetName implements [Field.GetName] interface method.
|
||||
func (f *GeoPointField) GetName() string {
|
||||
return f.Name
|
||||
}
|
||||
|
||||
// SetName implements [Field.SetName] interface method.
|
||||
func (f *GeoPointField) SetName(name string) {
|
||||
f.Name = name
|
||||
}
|
||||
|
||||
// GetSystem implements [Field.GetSystem] interface method.
|
||||
func (f *GeoPointField) GetSystem() bool {
|
||||
return f.System
|
||||
}
|
||||
|
||||
// SetSystem implements [Field.SetSystem] interface method.
|
||||
func (f *GeoPointField) SetSystem(system bool) {
|
||||
f.System = system
|
||||
}
|
||||
|
||||
// GetHidden implements [Field.GetHidden] interface method.
|
||||
func (f *GeoPointField) GetHidden() bool {
|
||||
return f.Hidden
|
||||
}
|
||||
|
||||
// SetHidden implements [Field.SetHidden] interface method.
|
||||
func (f *GeoPointField) SetHidden(hidden bool) {
|
||||
f.Hidden = hidden
|
||||
}
|
||||
|
||||
// ColumnType implements [Field.ColumnType] interface method.
|
||||
func (f *GeoPointField) ColumnType(app App) string {
|
||||
return `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
|
||||
}
|
||||
|
||||
// PrepareValue implements [Field.PrepareValue] interface method.
|
||||
func (f *GeoPointField) PrepareValue(record *Record, raw any) (any, error) {
|
||||
point := types.GeoPoint{}
|
||||
err := point.Scan(raw)
|
||||
return point, err
|
||||
}
|
||||
|
||||
// ValidateValue implements [Field.ValidateValue] interface method.
|
||||
func (f *GeoPointField) ValidateValue(ctx context.Context, app App, record *Record) error {
|
||||
val, ok := record.GetRaw(f.Name).(types.GeoPoint)
|
||||
if !ok {
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
// zero value
|
||||
if val.Lat == 0 && val.Lon == 0 {
|
||||
if f.Required {
|
||||
return validation.ErrRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if val.Lat < -90 || val.Lat > 90 {
|
||||
return validation.NewError("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.")
|
||||
}
|
||||
|
||||
if val.Lon < -180 || val.Lon > 180 {
|
||||
return validation.NewError("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSettings implements [Field.ValidateSettings] interface method.
|
||||
func (f *GeoPointField) ValidateSettings(ctx context.Context, app App, collection *Collection) error {
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestGeoPointFieldBaseMethods(t *testing.T) {
|
||||
testFieldBaseMethods(t, core.FieldTypeGeoPoint)
|
||||
}
|
||||
|
||||
func TestGeoPointFieldColumnType(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
f := &core.GeoPointField{}
|
||||
|
||||
expected := `JSON DEFAULT '{"lon":0,"lat":0}' NOT NULL`
|
||||
|
||||
if v := f.ColumnType(app); v != expected {
|
||||
t.Fatalf("Expected\n%q\ngot\n%q", expected, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldPrepareValue(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
f := &core.GeoPointField{}
|
||||
record := core.NewRecord(core.NewBaseCollection("test"))
|
||||
|
||||
scenarios := []struct {
|
||||
raw any
|
||||
expected string
|
||||
}{
|
||||
{nil, `{"lon":0,"lat":0}`},
|
||||
{"", `{"lon":0,"lat":0}`},
|
||||
{[]byte{}, `{"lon":0,"lat":0}`},
|
||||
{map[string]any{}, `{"lon":0,"lat":0}`},
|
||||
{types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
|
||||
{&types.GeoPoint{Lon: 10, Lat: 20}, `{"lon":10,"lat":20}`},
|
||||
{[]byte(`{"lon": 10, "lat": 20}`), `{"lon":10,"lat":20}`},
|
||||
{map[string]any{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
|
||||
{map[string]float64{"lon": 10, "lat": 20}, `{"lon":10,"lat":20}`},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%#v", i, s.raw), func(t *testing.T) {
|
||||
v, err := f.PrepareValue(record, s.raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawStr := string(raw)
|
||||
|
||||
if rawStr != s.expected {
|
||||
t.Fatalf("Expected\n%s\ngot\n%s", s.expected, rawStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldValidateValue(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := core.NewBaseCollection("test_collection")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
field *core.GeoPointField
|
||||
record func() *core.Record
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"invalid raw value",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", 123)
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"zero field value (non-required)",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"zero field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"non-zero Lat field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: 1})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"non-zero Lon field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: 1})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"non-zero Lat-Lon field value (required)",
|
||||
&core.GeoPointField{Name: "test", Required: true},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: -1, Lat: -2})
|
||||
return record
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"lat < -90",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: -90.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lat > 90",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lat: 90.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lon < -180",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: -180.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"lon > 180",
|
||||
&core.GeoPointField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.GeoPoint{Lon: 180.1})
|
||||
return record
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
err := s.field.ValidateValue(context.Background(), app, s.record())
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoPointFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeGeoPoint)
|
||||
testDefaultFieldHelpValidation[core.GeoPointField](t)
|
||||
}
|
||||
+8
-3
@@ -20,7 +20,7 @@ func init() {
|
||||
|
||||
const FieldTypeJSON = "json"
|
||||
|
||||
const DefaultJSONFieldMaxSize int64 = 5 << 20
|
||||
const DefaultJSONFieldMaxSize int64 = 1 << 20
|
||||
|
||||
var (
|
||||
_ Field = (*JSONField)(nil)
|
||||
@@ -45,15 +45,19 @@ type JSONField struct {
|
||||
// Hidden hides the field from the API response.
|
||||
Hidden bool `form:"hidden" json:"hidden"`
|
||||
|
||||
// ---
|
||||
|
||||
// Presentable hints the Dashboard UI to use the underlying
|
||||
// field record value in the relation preview label.
|
||||
Presentable bool `form:"presentable" json:"presentable"`
|
||||
|
||||
// ---
|
||||
// Help is an extra text explaining what the field is about.
|
||||
// It is usually shown in Dashboard UI under the field input.
|
||||
Help string `form:"help" json:"help"`
|
||||
|
||||
// MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||
//
|
||||
// If zero, a default limit of 5MB is applied.
|
||||
// If zero, a default limit of 1MB is applied.
|
||||
MaxSize int64 `form:"maxSize" json:"maxSize"`
|
||||
|
||||
// Required will require the field value to be non-empty JSON value
|
||||
@@ -181,6 +185,7 @@ func (f *JSONField) ValidateSettings(ctx context.Context, app App, collection *C
|
||||
return validation.ValidateStruct(f,
|
||||
validation.Field(&f.Id, validation.By(DefaultFieldIdValidationRule)),
|
||||
validation.Field(&f.Name, validation.By(DefaultFieldNameValidationRule)),
|
||||
validation.Field(&f.Help, validation.By(DefaultFieldHelpValidationRule)),
|
||||
validation.Field(&f.MaxSize, validation.Min(0), validation.Max(maxSafeJSONInt)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
&core.JSONField{Name: "test"},
|
||||
func() *core.Record {
|
||||
record := core.NewRecord(collection)
|
||||
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (5<<20))+`"`))
|
||||
record.SetRaw("test", types.JSONRaw(`"`+strings.Repeat("a", (1<<20))+`"`))
|
||||
return record
|
||||
},
|
||||
true,
|
||||
@@ -188,6 +188,7 @@ func TestJSONFieldValidateValue(t *testing.T) {
|
||||
func TestJSONFieldValidateSettings(t *testing.T) {
|
||||
testDefaultFieldIdValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldNameValidation(t, core.FieldTypeJSON)
|
||||
testDefaultFieldHelpValidation[core.JSONField](t)
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user