mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
880
Commits
v0.23.0-rc7
...
v0.39.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee115a99f | ||
|
|
b81d92dbd2 | ||
|
|
98a0f5eb05 | ||
|
|
3350ae651d | ||
|
|
394a46f3a2 | ||
|
|
4624e84b50 | ||
|
|
21dd105d9a | ||
|
|
8d60b928fc | ||
|
|
e7ae0bd716 | ||
|
|
4752ed84c7 | ||
|
|
5631d9b1c2 | ||
|
|
b36b8e35af | ||
|
|
9ff3e5a895 | ||
|
|
e5964e739e | ||
|
|
40d2849aa6 | ||
|
|
f3ae7731d4 | ||
|
|
1c3a912532 | ||
|
|
2b61b2babf | ||
|
|
d4b73af2e9 | ||
|
|
b11731222f | ||
|
|
f1873ad26d | ||
|
|
aeb78e51a1 | ||
|
|
541aa6b1e1 | ||
|
|
a9f259a1c7 | ||
|
|
a2d4f12071 | ||
|
|
846432f4c8 | ||
|
|
167e23e9ba | ||
|
|
ea2ef87d08 | ||
|
|
e067bf1517 | ||
|
|
97b85334a9 | ||
|
|
d4026ce60f | ||
|
|
40c631db32 | ||
|
|
095b0aca51 | ||
|
|
a7dfbbb8ec | ||
|
|
b65b7c5c56 | ||
|
|
4ebdfe4a1e | ||
|
|
19db35c48c | ||
|
|
1034dc9842 | ||
|
|
5bea84b94b | ||
|
|
7984e586a8 | ||
|
|
b022e138d4 | ||
|
|
db853850ab | ||
|
|
3d3d3b7355 | ||
|
|
3616b9d667 | ||
|
|
efd9604aa7 | ||
|
|
1ab6bba61b | ||
|
|
9e6b4eeda8 | ||
|
|
ea1a537a60 | ||
|
|
23a989118c | ||
|
|
d0b2551e78 | ||
|
|
b9b0e5ae80 | ||
|
|
f7fbc6c2c3 | ||
|
|
a286d28bf9 | ||
|
|
5cff808438 | ||
|
|
45e3ca6ea6 | ||
|
|
9c8557a124 | ||
|
|
8d7e3abbd6 | ||
|
|
9d50e20880 | ||
|
|
fbf4f3e5fb | ||
|
|
820b9afe98 | ||
|
|
b061673d9a | ||
|
|
c9c47dc75c | ||
|
|
cbb7c061a3 | ||
|
|
caad25bb61 | ||
|
|
3b98059a8a | ||
|
|
a8c236a54d | ||
|
|
81a5672498 | ||
|
|
1b5ea9a1fa | ||
|
|
d438c6a96a | ||
|
|
6828ca1d4e | ||
|
|
88193b9396 | ||
|
|
3cedf032ca | ||
|
|
4396369bb9 | ||
|
|
4a4f8ad9db | ||
|
|
5a144e1342 | ||
|
|
24d72877e9 | ||
|
|
c40f519b7b | ||
|
|
b026c6844b | ||
|
|
21a5524fed | ||
|
|
fe2d90641c | ||
|
|
8091a756e8 | ||
|
|
7542cd14e2 | ||
|
|
7da023f345 | ||
|
|
a104de5b9f | ||
|
|
97c5f3fa02 | ||
|
|
fccfa20653 | ||
|
|
29c6dacbfc | ||
|
|
d33927718b | ||
|
|
d153553d52 | ||
|
|
db88253aac | ||
|
|
0cf34c475b | ||
|
|
547ee715c0 | ||
|
|
4850da6f56 | ||
|
|
53ac0d29da | ||
|
|
d90aaedc00 | ||
|
|
74defc48b9 | ||
|
|
9205b11dc3 | ||
|
|
8d0881db3f | ||
|
|
dbcd95eb62 | ||
|
|
905256b0c8 | ||
|
|
602f3a4442 | ||
|
|
5c9bcfaf8e | ||
|
|
44bf55097a | ||
|
|
338d672bee | ||
|
|
5bd9d87bad | ||
|
|
6ba78d5218 | ||
|
|
260bd59c5b | ||
|
|
006566478a | ||
|
|
419f335f5b | ||
|
|
326f150db2 | ||
|
|
1c86addc4c | ||
|
|
494f47efb8 | ||
|
|
555a4f1a1e | ||
|
|
37b258810a | ||
|
|
ca7cf1162f | ||
|
|
dddb0a029f | ||
|
|
5d55fc18ee | ||
|
|
449e5af590 | ||
|
|
1e460d3f96 | ||
|
|
b5030ddfa1 | ||
|
|
fbeb09c40b | ||
|
|
69cdda4bf3 | ||
|
|
e708f39e1b | ||
|
|
52eccb3aac | ||
|
|
1d593476b0 | ||
|
|
8a04904de1 | ||
|
|
a3ac674f36 | ||
|
|
ae7041a889 | ||
|
|
257f03e1fa | ||
|
|
3566ba3729 | ||
|
|
a6002c4622 | ||
|
|
2ddf161314 | ||
|
|
b15f358fc9 | ||
|
|
83e44a7cfb | ||
|
|
866b8b8029 | ||
|
|
857214e10d | ||
|
|
592b13913f | ||
|
|
84b50c4869 | ||
|
|
3c33868ea8 | ||
|
|
223ac7a64a | ||
|
|
0cee0662f6 | ||
|
|
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 | ||
|
|
5aa380927a | ||
|
|
aaa3d67659 | ||
|
|
5c41938cb9 | ||
|
|
f1d44c7847 | ||
|
|
c0b7762abd | ||
|
|
26f0df36bc | ||
|
|
6a7f3a21fb | ||
|
|
2e26f61cb7 | ||
|
|
59f98cac99 | ||
|
|
2a1fdc1613 | ||
|
|
f767af0ded | ||
|
|
920e893e11 | ||
|
|
048e534f0d | ||
|
|
9da7a8f72b | ||
|
|
9856c59de0 | ||
|
|
acd72101c6 | ||
|
|
fa343c37e6 | ||
|
|
d3aac570b2 | ||
|
|
b066d9c429 | ||
|
|
5d889d00ad | ||
|
|
49baa69e8d | ||
|
|
923b791675 | ||
|
|
4b489b511d | ||
|
|
9b901fcee8 | ||
|
|
c5bd42a23f | ||
|
|
1bc0b78c83 | ||
|
|
6f3abe7c2d | ||
|
|
33340a6977 | ||
|
|
c101798516 | ||
|
|
65440314ce | ||
|
|
91d4ca5c06 | ||
|
|
a4a228b368 | ||
|
|
0ebe9c4faa | ||
|
|
7da875be14 | ||
|
|
2124b77a2a | ||
|
|
b0e2f67733 | ||
|
|
baf4857bee | ||
|
|
37ff943f67 | ||
|
|
c7322eec66 | ||
|
|
7964669a20 | ||
|
|
b8ea953059 | ||
|
|
f935e2139e | ||
|
|
25dd858c18 | ||
|
|
c70ca97888 | ||
|
|
0798b5ccbb | ||
|
|
e4d637e6e0 | ||
|
|
f4108cb354 | ||
|
|
2317695011 | ||
|
|
8b89bce2a8 | ||
|
|
023428ce8f | ||
|
|
7175992ce4 | ||
|
|
63fb2b6506 | ||
|
|
81f4db855b | ||
|
|
e103d987ce | ||
|
|
18d0b47aeb | ||
|
|
1e9847e924 | ||
|
|
c1c499fc1f | ||
|
|
accc51bf65 | ||
|
|
0aa21c1d04 | ||
|
|
cad16fac6b | ||
|
|
b150a3a98a | ||
|
|
41f1ff2b5f | ||
|
|
1286b59f54 | ||
|
|
9414986ca0 | ||
|
|
6628cdf893 | ||
|
|
e521e5343a | ||
|
|
412341fd78 | ||
|
|
ff3d51ce30 | ||
|
|
dadbca5248 | ||
|
|
1e2a923433 | ||
|
|
9e46d811d6 | ||
|
|
a8b9aee24e | ||
|
|
36df16aaa8 | ||
|
|
44a3e8478d | ||
|
|
73f1b223ff | ||
|
|
c8b29edf9d | ||
|
|
a43f4bf155 | ||
|
|
3074ed3c5e | ||
|
|
26cb1cef37 | ||
|
|
0155e9333f | ||
|
|
118399cc12 | ||
|
|
b062bc6d16 | ||
|
|
a8952cfca2 | ||
|
|
2af9b554ad | ||
|
|
2ef5459698 | ||
|
|
074e977e90 | ||
|
|
07fb052da1 | ||
|
|
6c53352643 | ||
|
|
00372711fd | ||
|
|
d34c8ec048 | ||
|
|
d8c0b11271 | ||
|
|
56f951e5a2 | ||
|
|
ed1917b307 | ||
|
|
47bd4ca11e | ||
|
|
4824701b6c | ||
|
|
39df26ee21 | ||
|
|
e18116d859 | ||
|
|
a8dbca64b2 | ||
|
|
3f25c71780 | ||
|
|
bae5421d62 | ||
|
|
f27d9f1dc9 | ||
|
|
aa52711cde | ||
|
|
07552c2809 | ||
|
|
f6407b903b | ||
|
|
2ebc6aecac | ||
|
|
884a3dec4a | ||
|
|
cb0335c2b6 | ||
|
|
274d499279 | ||
|
|
a3377c992b | ||
|
|
7147633f96 | ||
|
|
c847a6bc88 | ||
|
|
78a35a339f | ||
|
|
c97af83ed1 | ||
|
|
803941705c | ||
|
|
ef0170cf0b | ||
|
|
76b9051011 | ||
|
|
0d720c3c9d | ||
|
|
3e5e02a32c | ||
|
|
9b4200a65c | ||
|
|
cb3936a499 | ||
|
|
e34c25858c | ||
|
|
fd9ae0fd1c | ||
|
|
7ddb0db9a4 | ||
|
|
011f323bcc | ||
|
|
d5d764f83e | ||
|
|
239daf2023 | ||
|
|
2b2dafaf88 | ||
|
|
3098c2dcd8 | ||
|
|
8f32825cff | ||
|
|
20d378cd76 | ||
|
|
8e63e81561 | ||
|
|
efe4ef500b | ||
|
|
09a24e1de6 | ||
|
|
9490a220bc | ||
|
|
4f35fb74c8 | ||
|
|
f533320722 | ||
|
|
e51456bce2 | ||
|
|
35196674e6 | ||
|
|
7481c3f7f4 | ||
|
|
3ec10a9c7d | ||
|
|
9efd68ff4c | ||
|
|
3634fd9c26 | ||
|
|
9747f46c1d | ||
|
|
88a1867169 | ||
|
|
f7c85940c4 | ||
|
|
545a4eb47c | ||
|
|
5f660d8671 | ||
|
|
85c31ba068 | ||
|
|
6edb344ab3 | ||
|
|
c91d889da3 | ||
|
|
6a4e04533c | ||
|
|
e8f49c31e4 | ||
|
|
1abd6ca5c0 | ||
|
|
6f55695fa1 | ||
|
|
7c01441392 | ||
|
|
c6695b6a75 | ||
|
|
b0276ad605 | ||
|
|
376627b4cc | ||
|
|
0661de0604 | ||
|
|
f80de83234 | ||
|
|
2d828ef9eb | ||
|
|
77ac44a49a | ||
|
|
fb2763a697 | ||
|
|
5835a51111 | ||
|
|
3c2d43d37b | ||
|
|
6ee25cbe12 | ||
|
|
67e6be8073 | ||
|
|
d69e81922f | ||
|
|
51ac522e7f | ||
|
|
bcb2dca44e | ||
|
|
79f6f4ee60 | ||
|
|
ab7194a639 | ||
|
|
d92016af81 | ||
|
|
6b9d1b559c | ||
|
|
44f097f7d2 | ||
|
|
fcf65dcc77 | ||
|
|
06acaf38d8 | ||
|
|
cb2b27f6ed | ||
|
|
6e26cb5d88 | ||
|
|
f1b199b35c | ||
|
|
0ac4a388c0 | ||
|
|
aff8abccc7 | ||
|
|
51dfbc251a | ||
|
|
c933190db8 | ||
|
|
fab334fca6 | ||
|
|
5a5211d7f2 | ||
|
|
8f6e91c485 | ||
|
|
64fa4b9cef | ||
|
|
d5dddf3ead | ||
|
|
62a4795a4c | ||
|
|
fd18eb520f | ||
|
|
73361370f0 | ||
|
|
ef71daae65 | ||
|
|
5936fc3ac3 | ||
|
|
0efca0f936 | ||
|
|
1e92b51cf7 | ||
|
|
e9ece220d6 | ||
|
|
2dd4e38e1d | ||
|
|
b34453afc7 | ||
|
|
c3b347af4b | ||
|
|
68e9c55925 | ||
|
|
e5800875c2 | ||
|
|
2e43518bb4 | ||
|
|
e5f1bc3c37 | ||
|
|
e3bf81cb79 | ||
|
|
305e183f58 | ||
|
|
7795a086ef | ||
|
|
5d8a8dd7d8 | ||
|
|
31d3c27f43 | ||
|
|
c3ac28a8e6 | ||
|
|
ab26941d3f | ||
|
|
a18df14f4f | ||
|
|
c2e7ab8d41 | ||
|
|
7ee6b11e9d | ||
|
|
8ab02ce402 | ||
|
|
779f6c1a74 | ||
|
|
34d7ac0808 | ||
|
|
d0795bd849 | ||
|
|
08f2190ad1 | ||
|
|
d919d55b5e | ||
|
|
52c64318c5 | ||
|
|
9fe4f87e5b | ||
|
|
48328bf33f | ||
|
|
52e85a8036 | ||
|
|
37538f2a6d | ||
|
|
70df03ffbb | ||
|
|
487e83c84e | ||
|
|
14aaeb06fc | ||
|
|
19ea9cca36 | ||
|
|
6090b29070 | ||
|
|
957ba6266d | ||
|
|
e1472d922a | ||
|
|
3653e70a6d | ||
|
|
7f2fcc0046 | ||
|
|
32e0eadc2f | ||
|
|
18a7549e50 | ||
|
|
e53c30ca4d | ||
|
|
4adc4b28d2 | ||
|
|
c5554e22e0 | ||
|
|
846136dcfb | ||
|
|
bc378d33f6 | ||
|
|
cc833ad643 | ||
|
|
396aa0f97c | ||
|
|
9f606bdeca | ||
|
|
10a5c685ab | ||
|
|
1ca90e5e8b | ||
|
|
db57572a54 | ||
|
|
0af8f3cc66 | ||
|
|
438ecd88a0 | ||
|
|
45628a919f | ||
|
|
fc133d8665 | ||
|
|
5e6d4d2126 | ||
|
|
c38e7c36a6 | ||
|
|
a89960db71 | ||
|
|
7119ff4716 | ||
|
|
339399b0a4 | ||
|
|
9cb6adab4d | ||
|
|
f6aef4471d | ||
|
|
0e56521e8a | ||
|
|
4f8f320280 | ||
|
|
0daa584461 | ||
|
|
57f615a58c | ||
|
|
1bd0baf328 | ||
|
|
6694215909 | ||
|
|
241a81e1fc | ||
|
|
bed45beb13 | ||
|
|
e4cd6810ab | ||
|
|
f38700982c | ||
|
|
9506669095 | ||
|
|
4f67dba6cb | ||
|
|
47354f5aa9 | ||
|
|
2f35ef29fe | ||
|
|
755149c915 | ||
|
|
f9a2d6c6ae | ||
|
|
9e70c77736 | ||
|
|
b3d88349d7 | ||
|
|
8d0e4a0460 | ||
|
|
83d91b3dd5 | ||
|
|
8c71a291ff | ||
|
|
106ce0f0c4 | ||
|
|
c3557d4e94 | ||
|
|
1025fb2406 | ||
|
|
fadb4e3d67 | ||
|
|
a446290a7a | ||
|
|
bc83fddaf2 | ||
|
|
00da008d64 | ||
|
|
d3ca24e509 | ||
|
|
1a1e3a2741 | ||
|
|
b11ea60436 | ||
|
|
0c054bc6ff | ||
|
|
697545e73f | ||
|
|
39415e0f49 | ||
|
|
8b42941bd3 | ||
|
|
5a94ec9918 | ||
|
|
658f0c4177 | ||
|
|
421310d2f6 | ||
|
|
005047099d | ||
|
|
49db093a51 | ||
|
|
8646960abc | ||
|
|
9d2637847d | ||
|
|
747c490b0a | ||
|
|
cb46591e70 | ||
|
|
4c0b2154b0 | ||
|
|
8c45d4d92d | ||
|
|
0b7741f1f7 | ||
|
|
c41a4dfc07 | ||
|
|
f3c948aa13 | ||
|
|
be908ad4bf | ||
|
|
ae86525c13 |
+113
-2
@@ -1,5 +1,116 @@
|
||||
# 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 want to responsibly report a security issue you'll have to reach out as a human to **support at pocketbase.io**.
|
||||
|
||||
This means:
|
||||
- no overconfident and arrogant tone
|
||||
- no threatening deadlines
|
||||
- no requirement for me to login in your security platform just to read the report
|
||||
- no inflated severity (we can discuss the CVSS score after confirming the issue)
|
||||
- no LLMs usage as part of your report description or followup communication
|
||||
|
||||
Reports that don't follow the above will NOT be reviewed no matter of their validity _(you are of course free to publish whatever you want; see also [#7718](https://github.com/pocketbase/pocketbase/discussions/7718))_.
|
||||
|
||||
**Or in other words - a simple _"Hey I think I found a security issue when I do X"_ is enough.**
|
||||
|
||||
I try to be as responsive as possible and usually address security issues within couple days but if you didn't receive a reply from me for more than a week it is very likely that your email was flagged and in that case please open a GitHub issue or discussion just mentioning that you found a vulnerability and want to report it so that I can see the notification and will try to contact you for more details.
|
||||
|
||||
In case the vulnerability is confirmed:
|
||||
|
||||
- I'll start working on a local fix.
|
||||
- Once the fix is implemented locally, I'll publish a pre-announcement with a scheduled release date _(and when possible an approximate release time)_.
|
||||
- After the release, I'll publish a GitHub security advisory and CVE with remediation steps and **minimal** details regarding the found exploit _(you are free to publish PoC and more details in your own blog, gist, etc. but it is advised to wait at least a week after the release to allow enough time for people to patch their instances before making it more publicly known)_.
|
||||
|
||||
### Below is a short list of previous reports that are NOT considered security issues:
|
||||
|
||||
<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>Users enumeration</strong></summary>
|
||||
|
||||
This is a common and usually valid report but there is no easy solution without confusing and degrading the users experience.
|
||||
|
||||
Some endpoints, like the user create/register, can be used for usernames or emails enumeration based on various response heuristics - timing, specific error messages, etc.
|
||||
|
||||
In many places where applicable we've tried to minimize the impact by using constant time checks, returning non-descriptive error messages, applying an internal rate limit for some operations, etc. but it is not bulletproof and if somebody wants to find out if a user is registered they will be able to do it one way or another.
|
||||
|
||||
If you think that there is a place where we can improve the handling without hurting too much the user experience, feel free to open a regular public issue and it will be considered.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Attack-vectors relying on social engineering</strong></summary>
|
||||
|
||||
Reports for attacks relying on various social engineering tactics _(e.g. tricking someone to click on a link)_ are valid concerns but usually out of the security scope of the project as there are a lot of cases where the APIs are deliberately designed for minimal friction.
|
||||
|
||||
If you have concerns for such attack, feel free to open a regular public issue and we can eventually try to reconsider adding extra guards when feasible _(or at least properly document the existing behavior)_.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong><code>disintegration/imaging</code> CVE-2023-36308</strong></summary>
|
||||
|
||||
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.0'
|
||||
go-version: '>=1.26.4'
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -15,4 +15,6 @@ coverage.out
|
||||
# generated markdown previews
|
||||
README.html
|
||||
CHANGELOG.html
|
||||
CHANGELOG_16_22.html
|
||||
CHANGELOG_8_15.html
|
||||
LICENSE.html
|
||||
|
||||
+10
-13
@@ -9,19 +9,6 @@ before:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
# used only for tests
|
||||
- id: build_cgo
|
||||
main: ./examples/base
|
||||
binary: pocketbase
|
||||
ldflags:
|
||||
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
- id: build_noncgo
|
||||
main: ./examples/base
|
||||
binary: pocketbase
|
||||
@@ -37,13 +24,23 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- s390x
|
||||
- ppc64le
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: s390x
|
||||
- goos: windows
|
||||
goarch: ppc64le
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: s390x
|
||||
- goos: darwin
|
||||
goarch: ppc64le
|
||||
|
||||
release:
|
||||
draft: true
|
||||
|
||||
+993
-118
File diff suppressed because it is too large
Load Diff
+168
-3
@@ -2,6 +2,171 @@
|
||||
> For the most recent versions, please refer to [CHANGELOG.md](./CHANGELOG.md)
|
||||
---
|
||||
|
||||
## v0.22.46
|
||||
|
||||
- (_Backported from v0.39.1_) Bumped the min Go GitHub action version to 1.26.4 as it includes some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.4).
|
||||
|
||||
|
||||
## v0.22.45
|
||||
|
||||
- (_Backported from v0.38.2_) Updated all `golang.org/x/` packages to cover the recent [security fixes](https://groups.google.com/g/golang-announce/c/PdiGK3xulk4) _(none of them should be a critical issue in PocketBase but nonetheless it is advised to update)_.
|
||||
|
||||
|
||||
## v0.22.44
|
||||
|
||||
- (_Backported from v0.38.1_) Force unset the auth state of existing realtime connections on user tokenKey change.
|
||||
|
||||
|
||||
## v0.22.43
|
||||
|
||||
- (_Backported from v0.38.0_) Bumped min Go GitHub action version to 1.26.3 because it comes with some [minor bug and security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.3).
|
||||
|
||||
|
||||
## v0.22.42
|
||||
|
||||
- (_Backported from v0.37.4_) Adjusted Bitbucket, GitHub, GitLab and Gitea/Forgejo OAuth2 providers to better reflect recent API updates and doc references.
|
||||
_In case the userinfo data is not sufficient, some of the providers now send a separate list emails request in order to minimize eventual linking security issues caused by custom onpremise setups (e.g. Gitea/Forgejo allows skipping the email verification if an ENV variable is configured)._
|
||||
|
||||
- (_Backported from v0.37.4_) ⚠️ Fixed a pre-hijacking OAuth2 linking vulnerability ([#7662](https://github.com/pocketbase/pocketbase/discussions/7662)).
|
||||
|
||||
|
||||
## 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)).
|
||||
|
||||
- Bumped GitHub action min Go version to 1.23.5 as it comes with some [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.23.5).
|
||||
|
||||
|
||||
## v0.22.29
|
||||
|
||||
- (_Backported from v0.23.11_) Upgraded `golang.org/x/net` to 0.33.0 to fix [CVE-2024-45338](https://www.cve.org/CVERecord?id=CVE-2024-45338).
|
||||
_PocketBase uses the vulnerable functions primarily for the auto html->text mail generation, but most applications shouldn't be affected unless you are manually embedding unrestricted user provided value in your mail templates._
|
||||
|
||||
|
||||
## v0.22.28
|
||||
|
||||
- (_Backported from v0.23.10_) Renew the superuser file token cache when clicking on the thumb preview or download link ([#6137](https://github.com/pocketbase/pocketbase/discussions/6137)).
|
||||
|
||||
- (_Backported from v0.23.10_) Upgraded `modernc.org/sqlite` to 1.34.3 to fix "disk io" error on arm64 systems.
|
||||
_If you are extending PocketBase with Go and upgrading with `go get -u` make sure to manually set in your go.mod the `modernc.org/libc` indirect dependency to v1.55.3, aka. the exact same version the driver is using._
|
||||
|
||||
|
||||
## v0.22.27
|
||||
|
||||
- Instead of unregistering the realtime clients, we now just unset their auth state on delete of the related auth record so that the clients can receive the `delete` event ([#5898](https://github.com/pocketbase/pocketbase/issues/5898)).
|
||||
|
||||
|
||||
## v0.22.26
|
||||
|
||||
- (_Backported from v0.23.0-rc_) Added manual WAL checkpoints before creating the zip backup to minimize copying unnecessary data.
|
||||
|
||||
|
||||
## v0.22.25
|
||||
|
||||
- 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 possibility of eventual side-channel attacks.
|
||||
|
||||
|
||||
## v0.22.24
|
||||
|
||||
- Delete new uploaded record files in case of DB persist error ([#5845](https://github.com/pocketbase/pocketbase/issues/5845)).
|
||||
|
||||
|
||||
## v0.22.23
|
||||
|
||||
- Updated the hooks watcher to account for the case when hooksDir is a symlink ([#5789](https://github.com/pocketbase/pocketbase/issues/5789)).
|
||||
|
||||
- _(Backported from v0.23.0-rc)_ Registered a default `http.Server.ErrorLog` handler to report general server connection errors as app Debug level logs (e.g. invalid TLS handshakes caused by bots trying to access your server via its IP or other similar errors).
|
||||
|
||||
- Other minor fixes (updated npm dev deps to fix the vulnerabilities warning, added more user friendly realtime topic length error, regenerated JSVM types, etc.)
|
||||
|
||||
|
||||
## v0.22.22
|
||||
|
||||
- Added deprecation log in case Instagram OAuth2 is used (_related to [#5652](https://github.com/pocketbase/pocketbase/discussions/5652)_).
|
||||
|
||||
- Added `update` command warning to prevent unnecessary downloading PocketBase v0.23.0 since it will contain breaking changes.
|
||||
|
||||
- Added global JSVM `toString()` helper (_successor of `readerToString()`_) to stringify any value (bool, number, multi-byte array, io.Reader, etc.).
|
||||
_`readerToString` is still available but it is marked as deprecated. You can also use `toString` as replacement for of `String.fromCharCode` to properly stringify multi-byte unicode characters like emojis._
|
||||
```js
|
||||
decodeURIComponent(escape(String.fromCharCode(...bytes))) -> toString(bytes)
|
||||
```
|
||||
|
||||
- Updated `aws-sdk-go-v2` and removed deprecated `WithEndpointResolverWithOptions`.
|
||||
|
||||
- Backported some of the v0.23.0-rc form validators, fixes and tests.
|
||||
|
||||
- Bumped GitHub action min Go version and dependencies.
|
||||
|
||||
|
||||
## v0.22.21
|
||||
|
||||
- Lock the logs database during backup to prevent `database disk image is malformed` errors in case there is a log write running in the background ([#5541](https://github.com/pocketbase/pocketbase/discussions/5541)).
|
||||
@@ -368,7 +533,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
|
||||
@@ -748,7 +913,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)).
|
||||
|
||||
@@ -1093,7 +1258,7 @@
|
||||
|
||||
- Updated the schema fields Admin UI for "tidier" fields visualization.
|
||||
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forwarded-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".
|
||||
|
||||
- Added new `tools/archive` helper subpackage for managing archives (_currently works only with zip_).
|
||||
|
||||
|
||||
+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>
|
||||
|
||||
@@ -25,10 +25,13 @@
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
The easiest way to interact with the PocketBase Web APIs is to use one of the official SDK clients:
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_Browser, Node.js, React Native_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_Web, Mobile, Desktop, CLI_)
|
||||
|
||||
You could also check the recommendations in https://pocketbase.io/docs/how-to-use/.
|
||||
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -46,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
|
||||
@@ -54,7 +57,6 @@ Here is a minimal example:
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -65,7 +67,7 @@ Here is a minimal example:
|
||||
|
||||
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// registers new "GET /hello" route
|
||||
se.Router.Get("/hello", func(re *core.RequestEvent) error {
|
||||
se.Router.GET("/hello", func(re *core.RequestEvent) error {
|
||||
return re.String(200, "Hello world!")
|
||||
})
|
||||
|
||||
@@ -84,19 +86,13 @@ Here is a minimal example:
|
||||
|
||||
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
|
||||
|
||||
> [!NOTE]
|
||||
> PocketBase embeds SQLite, but doesn't require CGO.
|
||||
>
|
||||
> If CGO is enabled (aka. `CGO_ENABLED=1`), it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite).
|
||||
> Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
|
||||
|
||||
_For more details please refer to [Extend with Go](https://pocketbase.io/docs/go-overview/)._
|
||||
|
||||
### Building and running the repo main.go example
|
||||
|
||||
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.21+](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`
|
||||
@@ -114,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
|
||||
```
|
||||
@@ -148,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.
|
||||
|
||||
@@ -6,6 +6,11 @@ import "github.com/pocketbase/pocketbase/tools/router"
|
||||
// and for consistency with the JSVM binds.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// ToApiError wraps err into ApiError instance (if not already).
|
||||
func ToApiError(err error) *router.ApiError {
|
||||
return router.ToApiError(err)
|
||||
}
|
||||
|
||||
// NewApiError is an alias for [router.NewApiError].
|
||||
func NewApiError(status int, message string, errData any) *router.ApiError {
|
||||
return router.NewApiError(status, message, errData)
|
||||
|
||||
+9
-4
@@ -18,10 +18,10 @@ func bindBackupApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
sub := rg.Group("/backups")
|
||||
sub.GET("", backupsList).Bind(RequireSuperuserAuth())
|
||||
sub.POST("", backupCreate).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/upload", backupUpload).Bind(RequireSuperuserAuthOnlyIfAny())
|
||||
sub.POST("/upload", backupUpload).Bind(BodyLimit(0), RequireSuperuserAuth())
|
||||
sub.GET("/{key}", backupDownload) // relies on superuser file token
|
||||
sub.DELETE("/{key}", backupDelete).Bind(RequireSuperuserAuth())
|
||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuthOnlyIfAny())
|
||||
sub.POST("/{key}/restore", backupRestore).Bind(RequireSuperuserAuth())
|
||||
}
|
||||
|
||||
type backupFileInfo struct {
|
||||
@@ -70,8 +70,10 @@ func backupDownload(e *core.RequestEvent) error {
|
||||
return e.ForbiddenError("Insufficient permissions to access the resource.", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
allowedIPs := e.App.Settings().SuperuserIPs
|
||||
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||
return e.ForbiddenError("Insufficient permissions to access the resource.", nil)
|
||||
}
|
||||
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
@@ -79,6 +81,9 @@ func backupDownload(e *core.RequestEvent) error {
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
key := e.Request.PathValue("key")
|
||||
|
||||
+83
-60
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
@@ -53,7 +54,7 @@ func TestBackupsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{`[]`},
|
||||
@@ -64,7 +65,7 @@ func TestBackupsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -120,7 +121,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
@@ -137,7 +138,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
@@ -166,7 +167,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
URL: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"!test.zip"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
@@ -184,7 +185,7 @@ func TestBackupsCreate(t *testing.T) {
|
||||
URL: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"test.zip"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
@@ -289,7 +290,7 @@ func TestBackupUpload(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/upload",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
@@ -305,7 +306,7 @@ func TestBackupUpload(t *testing.T) {
|
||||
Body: bodies[3].buffer,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": bodies[3].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
@@ -335,7 +336,7 @@ func TestBackupUpload(t *testing.T) {
|
||||
Body: bodies[4].buffer,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": bodies[4].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
@@ -347,28 +348,17 @@ func TestBackupUpload(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with 0 superusers (valid file)",
|
||||
Name: "ensure that the default body limit is skipped",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/upload",
|
||||
Body: bodies[5].buffer,
|
||||
Body: bytes.NewBuffer(make([]byte, apis.DefaultMaxBodySize+100)),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": bodies[5].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
ExpectedStatus: 400, // it doesn't matter as long as it is not 413
|
||||
ExpectedContent: []string{`"data":{`},
|
||||
NotExpectedContent: []string{"entity too large"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -415,7 +405,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -468,7 +458,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with valid superuser auth token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -481,7 +471,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with expired superuser file token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.hTNDzikwJdcoWrLnRnp7xbaifZ2vuYZ0oOYRHtJfnk4",
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -494,20 +484,20 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with valid superuser file token but missing backup name",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
|
||||
URL: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with valid superuser file token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -524,7 +514,7 @@ func TestBackupsDownload(t *testing.T) {
|
||||
{
|
||||
Name: "with valid superuser file token and backup name with escaped char",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
|
||||
URL: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -538,6 +528,58 @@ func TestBackupsDownload(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with valid superuser file token AND whitelisted IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||
Headers: []string{"x-test-ip"},
|
||||
}
|
||||
|
||||
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
|
||||
|
||||
if err := app.Save(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
"storage/",
|
||||
"data.db",
|
||||
"auxiliary.db",
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "with valid superuser file token BUT non-whitelisted IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||
Headers: []string{"x-test-ip"},
|
||||
}
|
||||
|
||||
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||
|
||||
if err := app.Save(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -601,7 +643,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/backups/missing.zip",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -620,7 +662,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/backups/test1.zip",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -642,7 +684,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/backups/test1.zip",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -678,7 +720,7 @@ func TestBackupsDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/backups/%40test4.zip",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -751,7 +793,7 @@ func TestBackupsRestore(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/missing.zip/restore",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -767,7 +809,7 @@ func TestBackupsRestore(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/test1.zip/restore",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
@@ -780,25 +822,6 @@ func TestBackupsRestore(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with no superusers (checks only access)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/backups/missing.zip/restore",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+10
-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)
|
||||
@@ -31,9 +31,11 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
pbRouter.Bind(panicRecover())
|
||||
pbRouter.Bind(rateLimit())
|
||||
pbRouter.Bind(loadAuthToken())
|
||||
pbRouter.Bind(superuserIPsWhitelist())
|
||||
pbRouter.Bind(securityHeaders())
|
||||
pbRouter.Bind(BodyLimit(DefaultMaxBodySize))
|
||||
|
||||
// API routes
|
||||
apiGroup := pbRouter.Group("/api")
|
||||
bindSettingsApi(app, apiGroup)
|
||||
bindCollectionApi(app, apiGroup)
|
||||
@@ -41,10 +43,15 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
|
||||
bindRecordAuthApi(app, apiGroup)
|
||||
bindLogsApi(app, apiGroup)
|
||||
bindBackupApi(app, apiGroup)
|
||||
bindCronApi(app, apiGroup)
|
||||
bindFileApi(app, apiGroup)
|
||||
bindBatchApi(app, apiGroup)
|
||||
bindRealtimeApi(app, apiGroup)
|
||||
bindHealthApi(app, apiGroup)
|
||||
bindSQLApi(app, apiGroup)
|
||||
|
||||
// UI routes
|
||||
bindUIExtensions(app)
|
||||
|
||||
return pbRouter, nil
|
||||
}
|
||||
@@ -85,7 +92,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.
|
||||
@@ -93,7 +100,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:
|
||||
//
|
||||
|
||||
+21
-14
@@ -29,14 +29,14 @@ func bindBatchApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
|
||||
type HandleFunc func(e *core.RequestEvent) error
|
||||
|
||||
type BatchActionHandlerFunc func(app core.App, ir *core.InternalRequest, params map[string]string, next func() error) HandleFunc
|
||||
type BatchActionHandlerFunc func(app core.App, ir *core.InternalRequest, params map[string]string, next func(data any) error) HandleFunc
|
||||
|
||||
// ValidBatchActions defines a map with the supported batch InternalRequest actions.
|
||||
//
|
||||
// Note: when adding new routes make sure that their middlewares are inlined!
|
||||
var ValidBatchActions = map[*regexp.Regexp]BatchActionHandlerFunc{
|
||||
// "upsert" handler
|
||||
regexp.MustCompile(`^PUT /api/collections/(?P<collection>[^\/\?]+)/records(?P<query>\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func() error) HandleFunc {
|
||||
regexp.MustCompile(`^PUT /api/collections/(?P<collection>[^\/\?]+)/records(?P<query>\?.*)?$`): func(app core.App, ir *core.InternalRequest, params map[string]string, next func(any) error) HandleFunc {
|
||||
var id string
|
||||
if len(ir.Body) > 0 && ir.Body["id"] != "" {
|
||||
id = cast.ToString(ir.Body["id"])
|
||||
@@ -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() error) HandleFunc {
|
||||
return recordCreate(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(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() error) HandleFunc {
|
||||
return recordUpdate(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(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() error) HandleFunc {
|
||||
return recordDelete(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(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 {
|
||||
@@ -246,7 +246,7 @@ func (p *batchProcessor) process(activeApp core.App, batch []*core.InternalReque
|
||||
p.baseEvent,
|
||||
batch[0],
|
||||
p.infoContext,
|
||||
func() error {
|
||||
func(_ any) error {
|
||||
if len(batch) == 1 {
|
||||
return nil
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func processInternalRequest(
|
||||
baseEvent *core.RequestEvent,
|
||||
ir *core.InternalRequest,
|
||||
infoContext string,
|
||||
optNext func() error,
|
||||
optNext func(data any) error,
|
||||
) (*BatchRequestResult, error) {
|
||||
handle, params, ok := prepareInternalAction(activeApp, ir, optNext)
|
||||
if !ok {
|
||||
@@ -338,6 +338,12 @@ func processInternalRequest(
|
||||
// apply batch request specific headers
|
||||
// ---
|
||||
for k, v := range ir.Headers {
|
||||
// individual Authorization header keys don't have affect
|
||||
// because the auth state is populated from the base event
|
||||
if strings.EqualFold(k, "authorization") {
|
||||
continue
|
||||
}
|
||||
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
r.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
@@ -358,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()
|
||||
@@ -475,7 +482,7 @@ func extractPrefixedFiles(request *http.Request, prefixes ...string) (map[string
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func prepareInternalAction(activeApp core.App, ir *core.InternalRequest, optNext func() error) (HandleFunc, map[string]string, bool) {
|
||||
func prepareInternalAction(activeApp core.App, ir *core.InternalRequest, optNext func(data any) error) (HandleFunc, map[string]string, bool) {
|
||||
full := strings.ToUpper(ir.Method) + " " + ir.URL
|
||||
|
||||
for re, actionFactory := range ValidBatchActions {
|
||||
|
||||
+78
-14
@@ -224,7 +224,7 @@ func TestBatchRequest(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mixed create/update/delete (rules failure)",
|
||||
Name: "mixed create/update/delete (non-superuser rule failure)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -284,6 +284,70 @@ func TestBatchRequest(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mixed create/update/delete (superuser rule failure)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Headers: map[string]string{
|
||||
// test@example.com, clients
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
|
||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}},
|
||||
{"method":"POST", "url":"/api/collections/_superusers/records", "body": {"email":"test_batch@example.com","password":"1234567890"}}
|
||||
]
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"requests":{`,
|
||||
`"2":{"code":"batch_request_failed"`,
|
||||
`403`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"0":`,
|
||||
`"1":`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnBatchRequest": 1,
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateError": 1,
|
||||
"OnModelDelete": 1,
|
||||
"OnModelDeleteExecute": 1,
|
||||
"OnModelAfterDeleteError": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordUpdateRequest": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateError": 1,
|
||||
"OnRecordDeleteRequest": 1,
|
||||
"OnRecordDelete": 1,
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteError": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err = app.FindRecordById("demo2", "achvryl401bhse3")
|
||||
if err != nil {
|
||||
t.Fatal("Expected record to not be deleted")
|
||||
}
|
||||
|
||||
_, err = app.FindFirstRecordByFilter("demo3", `title="batch_update"`)
|
||||
if err == nil {
|
||||
t.Fatal("Expected record to not be updated")
|
||||
}
|
||||
|
||||
_, err = app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test_batch@example.com")
|
||||
if err == nil {
|
||||
t.Fatal("Expected superuser to not be created")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mixed create/update/delete (rules success)",
|
||||
Method: http.MethodPost,
|
||||
@@ -294,9 +358,9 @@ func TestBatchRequest(t *testing.T) {
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}},
|
||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3"},
|
||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}}
|
||||
{"method":"POST", "url":"/api/collections/demo2/records", "body": {"title": "batch_create"}, "headers": {"Authorization": "ignored"}},
|
||||
{"method":"DELETE", "url":"/api/collections/demo2/records/achvryl401bhse3", "headers": {"Authorization": "ignored"}},
|
||||
{"method":"PATCH", "url":"/api/collections/demo3/records/1tmknxy2868d869", "body": {"title": "batch_update"}, "headers": {"Authorization": "ignored"}}
|
||||
]
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
@@ -360,8 +424,8 @@ func TestBatchRequest(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Headers: map[string]string{
|
||||
// test@example.com, superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
// test@example.com, _superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
@@ -431,8 +495,8 @@ func TestBatchRequest(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Headers: map[string]string{
|
||||
// test@example.com, superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
// test@example.com, _superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
@@ -494,8 +558,8 @@ func TestBatchRequest(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
// test@example.com, superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
// test@example.com, _superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().Batch.Timeout = 1
|
||||
@@ -620,8 +684,8 @@ func TestBatchRequest(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Headers: map[string]string{
|
||||
// test@example.com, superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
// test@example.com, _superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
@@ -667,8 +731,8 @@ func TestBatchRequest(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/batch",
|
||||
Headers: map[string]string{
|
||||
// test@example.com, superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
// test@example.com, _superusers
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{
|
||||
"requests": [
|
||||
|
||||
+115
-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,9 +199,98 @@ func collectionTruncate(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
func collectionScaffolds(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]*core.Collection{
|
||||
core.CollectionTypeBase: core.NewBaseCollection(""),
|
||||
core.CollectionTypeAuth: core.NewAuthCollection(""),
|
||||
core.CollectionTypeView: core.NewViewCollection(""),
|
||||
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("", randomId),
|
||||
core.CollectionTypeAuth: core.NewAuthCollection("", randomId),
|
||||
core.CollectionTypeView: core.NewViewCollection("", randomId),
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
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"
|
||||
@@ -40,7 +41,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
URL: "/api/collections/import",
|
||||
Body: strings.NewReader(`{"collections":[]}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -79,7 +80,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -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,
|
||||
@@ -145,7 +212,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -199,7 +266,7 @@ func TestCollectionsImport(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -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 {
|
||||
|
||||
+335
-77
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -42,7 +41,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -80,7 +79,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections?page=2&perPage=2&sort=-created",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -100,7 +99,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections?filter=invalidfield~'demo2'",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -111,7 +110,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections?filter=name~'demo'",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -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 {
|
||||
@@ -165,7 +190,7 @@ func TestCollectionView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/missing",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -176,7 +201,7 @@ func TestCollectionView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -193,7 +218,7 @@ func TestCollectionView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/wsmn24bux7wo113",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -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 {
|
||||
@@ -249,7 +300,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/missing",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -260,7 +311,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo5",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
@@ -283,7 +334,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/9n89pl5vkct6330",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
@@ -306,7 +357,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/" + core.CollectionNameMFAs,
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -326,7 +377,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo2",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -346,7 +397,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/view2",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -361,23 +412,30 @@ 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{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -416,7 +474,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
URL: "/api/collections",
|
||||
Body: strings.NewReader(``),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -443,7 +501,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
URL: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"demo1","type":"base","fields":[{"type":"text","name":""}]}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -469,7 +527,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
URL: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base","fields":[{"type":"text","id":"12345789","name":"test"}]}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -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,
|
||||
@@ -503,11 +561,11 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"emailChangeToken":{"duration":123},
|
||||
"fields":[
|
||||
{"type":"text","id":"12345789","name":"test"},
|
||||
{"type":"text","name":"tokenKey","required":false,"min":10}
|
||||
{"type":"text","name":"tokenKey","system":true,"required":false,"min":10}
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -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":"`,
|
||||
@@ -567,7 +625,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -603,7 +661,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -636,7 +694,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -656,24 +714,31 @@ 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"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
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,10 +751,10 @@ 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.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -715,16 +780,16 @@ 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.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"view"`,
|
||||
`"fields":[{"autogeneratePattern":"","hidden":false,"id":"text3208210256","max":0,"min":0,"name":"id","pattern":"","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,
|
||||
@@ -758,7 +823,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -793,14 +858,14 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
demo1, err := app.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
demo1.AddIndex("exist_test", false, "created", "")
|
||||
demo1.AddIndex("exist_test", false, "updated", "")
|
||||
if err = app.Save(demo1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -835,7 +900,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -870,7 +935,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -945,7 +1010,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
URL: "/api/collections/missing",
|
||||
Body: strings.NewReader(`{}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -957,7 +1022,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
URL: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -978,24 +1043,31 @@ 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(`{}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
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)",
|
||||
@@ -1006,7 +1078,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"type":"auth"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1031,7 +1103,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
URL: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{"name":"new"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1073,7 +1145,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1109,7 +1181,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1135,7 +1207,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"passwordAuth":{"identityFields": ["missing"]}
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1165,7 +1237,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"viewQuery":"invalid"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1190,10 +1262,10 @@ 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.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1232,7 +1304,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1261,14 +1333,14 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
demo1, err := app.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
demo1.AddIndex("exist_test", false, "created", "")
|
||||
demo1.AddIndex("exist_test", false, "updated", "")
|
||||
if err = app.Save(demo1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1301,7 +1373,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1332,7 +1404,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
]
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1405,10 +1477,12 @@ func TestCollectionScaffolds(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/meta/scaffolds",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":""`,
|
||||
`"name":""`,
|
||||
`"auth":{`,
|
||||
`"base":{`,
|
||||
`"view":{`,
|
||||
@@ -1455,7 +1529,7 @@ func TestCollectionTruncate(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo5/truncate",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -1473,7 +1547,7 @@ func TestCollectionTruncate(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo3/truncate",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -1498,7 +1572,7 @@ func TestCollectionTruncate(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/view2/truncate",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -1510,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,59 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
)
|
||||
|
||||
// bindCronApi registers the crons api endpoint.
|
||||
func bindCronApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup := rg.Group("/crons").Bind(RequireSuperuserAuth())
|
||||
subGroup.GET("", cronsList)
|
||||
subGroup.POST("/{id}", cronRun)
|
||||
}
|
||||
|
||||
func cronsList(e *core.RequestEvent) error {
|
||||
jobs := e.App.Cron().Jobs()
|
||||
|
||||
slices.SortStableFunc(jobs, func(a, b *cron.Job) int {
|
||||
if strings.HasPrefix(a.Id(), "__pb") {
|
||||
return 1
|
||||
}
|
||||
if strings.HasPrefix(b.Id(), "__pb") {
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(a.Id(), b.Id())
|
||||
})
|
||||
|
||||
return e.JSON(http.StatusOK, jobs)
|
||||
}
|
||||
|
||||
func cronRun(e *core.RequestEvent) error {
|
||||
cronId := e.Request.PathValue("id")
|
||||
|
||||
var foundJob *cron.Job
|
||||
|
||||
jobs := e.App.Cron().Jobs()
|
||||
for _, j := range jobs {
|
||||
if j.Id() == cronId {
|
||||
foundJob = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundJob == nil {
|
||||
return e.NotFoundError("Missing or invalid cron job", nil)
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
foundJob.Run()
|
||||
})
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestCronsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (empty list)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Cron().RemoveAll()
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{`[]`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/crons",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`{"id":"__pbLogsCleanup__","expression":"0 */6 * * *"}`,
|
||||
`{"id":"__pbDBOptimize__","expression":"0 0 * * *"}`,
|
||||
`{"id":"__pbMFACleanup__","expression":"0 * * * *"}`,
|
||||
`{"id":"__pbOTPCleanup__","expression":"0 * * * *"}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronsRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
beforeTestFunc := func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Cron().Add("test", "* * * * *", func() {
|
||||
app.Store().Set("testJobCalls", cast.ToInt(app.Store().Get("testJobCalls"))+1)
|
||||
})
|
||||
}
|
||||
|
||||
expectedCalls := func(expected int) func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
return func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
total := cast.ToInt(app.Store().Get("testJobCalls"))
|
||||
if total != expected {
|
||||
t.Fatalf("Expected total testJobCalls %d, got %d", expected, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (missing job)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/missing",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(0),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "authorized as superuser (existing job)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/crons/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 50 * time.Millisecond,
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
AfterTestFunc: expectedCalls(1),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
const installerParam = "pbinstal"
|
||||
|
||||
var wildcardPlaceholderRegex = regexp.MustCompile(`/{.+\.\.\.}$`)
|
||||
|
||||
func stripWildcard(pattern string) string {
|
||||
return wildcardPlaceholderRegex.ReplaceAllString(pattern, "")
|
||||
}
|
||||
|
||||
// installerRedirect redirects the user to the installer dashboard UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App, cpPath string) func(*core.RequestEvent) error {
|
||||
// note: to avoid locks contention it is not concurrent safe but it
|
||||
// is expected to be updated only once during initialization
|
||||
var hasSuperuser bool
|
||||
|
||||
// strip named wildcard
|
||||
cpPath = stripWildcard(cpPath)
|
||||
|
||||
updateHasSuperuser := func(app core.App) error {
|
||||
total, err := app.CountRecords(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSuperuser = total > 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load initial state on app init
|
||||
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateHasSuperuser(e.App)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for existing superuser: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// update on superuser create
|
||||
app.OnRecordCreateRequest(core.CollectionNameSuperusers).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasSuperuser {
|
||||
hasSuperuser = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return func(e *core.RequestEvent) error {
|
||||
if hasSuperuser {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
isAPI := strings.HasPrefix(e.Request.URL.Path, "/api/")
|
||||
isControlPanel := strings.HasPrefix(e.Request.URL.Path, cpPath)
|
||||
wildcard := e.Request.PathValue(StaticWildcardParam)
|
||||
|
||||
// skip redirect checks for API and non-root level dashboard index.html requests (css, images, etc.)
|
||||
if isAPI || (isControlPanel && wildcard != "" && wildcard != router.IndexPage) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// check again in case the superuser was created by some other process
|
||||
if err := updateHasSuperuser(e.App); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasSuperuser {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
|
||||
|
||||
// redirect to the installer page
|
||||
if !hasInstallerParam {
|
||||
return e.Redirect(http.StatusTemporaryRedirect, cpPath+"?"+installerParam+"#")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardRemoveInstallerParam redirects to a non-installer
|
||||
// query param in case there is already a superuser created.
|
||||
//
|
||||
// Note: intended to be registered only for the dashboard route
|
||||
// to prevent excessive checks for every other route in installerRedirect.
|
||||
func dashboardRemoveInstallerParam() func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
_, hasInstallerParam := e.Request.URL.Query()[installerParam]
|
||||
if !hasInstallerParam {
|
||||
return e.Next() // nothing to remove
|
||||
}
|
||||
|
||||
// clear installer param
|
||||
total, _ := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
if total > 0 {
|
||||
return e.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardCacheControl adds default Cache-Control header for all
|
||||
// dashboard UI resources (ignoring the root index.html path)
|
||||
func dashboardCacheControl() func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Request.PathValue(StaticWildcardParam) != "" {
|
||||
e.Response.Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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
|
||||
// (the await/async is for top-level await)
|
||||
_, _ = buf.WriteString("await (async 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{"await (async function(){ext1_main})();await (async 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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+52
-23
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
@@ -13,19 +14,30 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
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.
|
||||
func bindFileApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
maxWorkers := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WORKERS"))
|
||||
if maxWorkers <= 0 {
|
||||
maxWorkers = int64(runtime.NumCPU() + 2) // the value is arbitrary chosen and may change in the future
|
||||
}
|
||||
|
||||
maxWait := cast.ToInt64(os.Getenv("PB_THUMBS_MAX_WAIT"))
|
||||
if maxWait <= 0 {
|
||||
maxWait = 60
|
||||
}
|
||||
|
||||
api := fileApi{
|
||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
|
||||
thumbGenPending: new(singleflight.Group),
|
||||
thumbGenMaxWait: 60 * time.Second,
|
||||
thumbGenSem: semaphore.NewWeighted(maxWorkers),
|
||||
thumbGenMaxWait: time.Duration(maxWait) * time.Second,
|
||||
}
|
||||
|
||||
sub := rg.Group("/files")
|
||||
@@ -48,6 +60,7 @@ type fileApi struct {
|
||||
}
|
||||
|
||||
func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
||||
// extra check for just in case the handler is called in a different context
|
||||
if e.Auth == nil {
|
||||
return e.UnauthorizedError("Missing auth context.", nil)
|
||||
}
|
||||
@@ -60,10 +73,11 @@ func (api *fileApi) fileToken(e *core.RequestEvent) error {
|
||||
event := new(core.FileTokenRequestEvent)
|
||||
event.RequestEvent = e
|
||||
event.Token = token
|
||||
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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -101,6 +115,15 @@ func (api *fileApi) download(e *core.RequestEvent) error {
|
||||
token := e.Request.URL.Query().Get("token")
|
||||
authRecord, _ := e.App.FindAuthRecordByToken(token, core.TokenTypeFile)
|
||||
|
||||
// reset the auth state if it is superuser and it is not whitelisted
|
||||
// (not critical because file tokens are short-lived but checked nonetheless as an extra precaution)
|
||||
if authRecord != nil && authRecord.IsSuperuser() {
|
||||
allowedIPs := e.App.Settings().SuperuserIPs
|
||||
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||
authRecord = nil
|
||||
}
|
||||
}
|
||||
|
||||
// create a shallow copy of the cached request data and adjust it to the current auth record (if any)
|
||||
requestInfo := *originalRequestInfo
|
||||
requestInfo.Context = core.RequestInfoContextProtectedFile
|
||||
@@ -129,8 +152,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")
|
||||
@@ -144,34 +173,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
|
||||
@@ -179,7 +205,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)
|
||||
}
|
||||
|
||||
|
||||
+136
-28
@@ -49,7 +49,7 @@ func TestFileToken(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/files/token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -65,7 +65,7 @@ func TestFileToken(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/files/token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.OnFileTokenRequest().BindFunc(func(e *core.FileTokenRequestEvent) error {
|
||||
@@ -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{
|
||||
@@ -273,7 +337,7 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "protected file - superuser with expired file token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.hTNDzikwJdcoWrLnRnp7xbaifZ2vuYZ0oOYRHtJfnk4",
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.nqqtqpPhxU0045F4XP_ruAkzAidYBc5oPy9ErN3XBq0",
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
@@ -281,7 +345,51 @@ func TestFileDownload(t *testing.T) {
|
||||
{
|
||||
Name: "protected file - superuser with valid file token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - superuser with non-whitelisted IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||
Headers: []string{"x-test-ip"},
|
||||
}
|
||||
|
||||
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||
|
||||
err := app.Save(app.Settings())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "protected file - superuser with whitelisted IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||
Headers: []string{"x-test-ip"},
|
||||
}
|
||||
|
||||
app.Settings().SuperuserIPs = []string{"127.0.0.1"}
|
||||
|
||||
if err := app.Save(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
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)
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ func TestHealthAPI(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/health",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"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.
|
||||
//
|
||||
// It will attempt to open a link in the browser (with a short-lived auth
|
||||
// token for the systemSuperuser) to the installer UI so that users can
|
||||
// create their own custom superuser record.
|
||||
//
|
||||
// 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/_/#/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", executablePath())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadInstaller(
|
||||
app core.App,
|
||||
baseURL string,
|
||||
installerFunc func(app core.App, systemSuperuser *core.Record, baseURL string) error,
|
||||
) error {
|
||||
if installerFunc == nil || !needInstallerSuperuser(app) {
|
||||
return nil
|
||||
}
|
||||
|
||||
superuser, err := findOrCreateInstallerSuperuser(app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return installerFunc(app, superuser, baseURL)
|
||||
}
|
||||
|
||||
func needInstallerSuperuser(app core.App) bool {
|
||||
total, err := app.CountRecords(core.CollectionNameSuperusers, dbx.Not(dbx.HashExp{
|
||||
"email": core.DefaultInstallerEmail,
|
||||
}))
|
||||
|
||||
return err == nil && total == 0
|
||||
}
|
||||
|
||||
func findOrCreateInstallerSuperuser(app core.App) (*core.Record, error) {
|
||||
col, err := app.FindCachedCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := app.FindAuthRecordByEmail(col, core.DefaultInstallerEmail)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record = core.NewRecord(col)
|
||||
record.SetEmail(core.DefaultInstallerEmail)
|
||||
record.SetRandomPassword()
|
||||
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func executablePath() string {
|
||||
if osutils.IsProbablyGoRun() {
|
||||
return "go run ."
|
||||
}
|
||||
|
||||
return os.Args[0]
|
||||
}
|
||||
+6
-6
@@ -36,7 +36,7 @@ func TestLogsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
@@ -59,7 +59,7 @@ func TestLogsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs?filter=data.status>200",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
@@ -111,7 +111,7 @@ func TestLogView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
@@ -127,7 +127,7 @@ func TestLogView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
@@ -175,7 +175,7 @@ func TestLogsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs/stats",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
@@ -192,7 +192,7 @@ func TestLogsStats(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/logs/stats?filter=data.status>200",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubLogsData(app); err != nil {
|
||||
|
||||
+64
-44
@@ -42,13 +42,15 @@ const (
|
||||
DefaultLoadAuthTokenMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 20
|
||||
DefaultLoadAuthTokenMiddlewareId = "pbLoadAuthToken"
|
||||
|
||||
DefaultSuperuserIPsWhitelistMiddlewarePriority = DefaultLoadAuthTokenMiddlewarePriority + 5
|
||||
DefaultSuperuserIPsWhitelistMiddlewareId = "pbSuperuserIPsWhitelist"
|
||||
|
||||
DefaultSecurityHeadersMiddlewarePriority = DefaultRateLimitMiddlewarePriority - 10
|
||||
DefaultSecurityHeadersMiddlewareId = "pbSecurityHeaders"
|
||||
|
||||
DefaultRequireGuestOnlyMiddlewareId = "pbRequireGuestOnly"
|
||||
DefaultRequireAuthMiddlewareId = "pbRequireAuth"
|
||||
DefaultRequireSuperuserAuthMiddlewareId = "pbRequireSuperuserAuth"
|
||||
DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId = "pbRequireSuperuserAuthOnlyIfAny"
|
||||
DefaultRequireSuperuserOrOwnerAuthMiddlewareId = "pbRequireSuperuserOrOwnerAuth"
|
||||
DefaultRequireSameCollectionContextAuthMiddlewareId = "pbRequireSameCollectionContextAuth"
|
||||
)
|
||||
@@ -110,31 +112,6 @@ func RequireSuperuserAuth() *hook.Handler[*core.RequestEvent] {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSuperuserAuthOnlyIfAny middleware requires a request to have
|
||||
// a valid superuser Authorization header ONLY if the application has
|
||||
// at least 1 existing superuser.
|
||||
func RequireSuperuserAuthOnlyIfAny() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: DefaultRequireSuperuserAuthOnlyIfAnyMiddlewareId,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
totalSuperusers, err := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to fetch superusers info.", err)
|
||||
}
|
||||
|
||||
if totalSuperusers == 0 {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
return requireAuth(core.CollectionNameSuperusers)(e)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSuperuserOrOwnerAuth middleware requires a request to have
|
||||
// a valid superuser or regular record owner Authorization header set.
|
||||
//
|
||||
@@ -196,7 +173,9 @@ func RequireSameCollectionContextAuth(collectionPathParam string) *hook.Handler[
|
||||
|
||||
// loadAuthToken attempts to load the auth context based on the "Authorization: TOKEN" header value.
|
||||
//
|
||||
// This middleware does nothing in case of missing, invalid or expired token.
|
||||
// This middleware does nothing in case of:
|
||||
// - missing, invalid or expired token
|
||||
// - e.Auth is already loaded by another middleware
|
||||
//
|
||||
// This middleware is registered by default for all routes.
|
||||
//
|
||||
@@ -207,6 +186,11 @@ func loadAuthToken() *hook.Handler[*core.RequestEvent] {
|
||||
Id: DefaultLoadAuthTokenMiddlewareId,
|
||||
Priority: DefaultLoadAuthTokenMiddlewarePriority,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
// already loaded by another middleware
|
||||
if e.Auth != nil {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
token := getAuthTokenFromRequest(e)
|
||||
if token == "" {
|
||||
return e.Next()
|
||||
@@ -226,11 +210,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
|
||||
}
|
||||
|
||||
@@ -246,9 +232,15 @@ func wwwRedirect(redirectHosts []string) *hook.Handler[*core.RequestEvent] {
|
||||
host := e.Request.Host
|
||||
|
||||
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, redirectHosts) {
|
||||
// note: e.Request.URL.Scheme would be empty
|
||||
schema := "http://"
|
||||
if e.IsTLS() {
|
||||
schema = "https://"
|
||||
}
|
||||
|
||||
return e.Redirect(
|
||||
http.StatusTemporaryRedirect,
|
||||
(e.Request.URL.Scheme + "://" + host[4:] + e.Request.RequestURI),
|
||||
(schema + host[4:] + e.Request.RequestURI),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -310,6 +302,28 @@ func securityHeaders() *hook.Handler[*core.RequestEvent] {
|
||||
}
|
||||
}
|
||||
|
||||
// superuserIPsWhitelist middleware checks the current authenticated superuser IP
|
||||
// against the configured SuperuserIPs whitelist setting.
|
||||
//
|
||||
// This middleware is registered by default for all routes.
|
||||
func superuserIPsWhitelist() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: DefaultSuperuserIPsWhitelistMiddlewareId,
|
||||
Priority: DefaultSuperuserIPsWhitelistMiddlewarePriority,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
ips := e.App.Settings().SuperuserIPs
|
||||
|
||||
if len(ips) > 0 && !isIPInList(ips, e.RealIP()) {
|
||||
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SkipSuccessActivityLog is a helper middleware that instructs the global
|
||||
// activity logger to log only requests that have failed/returned an error.
|
||||
func SkipSuccessActivityLog() *hook.Handler[*core.RequestEvent] {
|
||||
@@ -378,11 +392,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 {
|
||||
@@ -410,17 +438,9 @@ func logRequest(event *core.RequestEvent, err error) {
|
||||
}
|
||||
|
||||
if event.App.Settings().Logs.LogIP {
|
||||
var userIP string
|
||||
if len(event.App.Settings().TrustedProxy.Headers) > 0 {
|
||||
userIP = event.RealIP()
|
||||
} else {
|
||||
// fallback to the legacy behavior (it is "safe" since it is only for log purposes)
|
||||
userIP = cutStr(event.UnsafeRealIP(), 50)
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("userIP", userIP),
|
||||
slog.String("userIP", event.RealIP()),
|
||||
slog.String("remoteIP", event.RemoteIP()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ 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"
|
||||
DefaultBodyLimitMiddlewarePriority = DefaultRateLimitMiddlewarePriority + 10
|
||||
)
|
||||
|
||||
// BodyLimit returns a middleware function that changes the default request body size limit.
|
||||
// BodyLimit returns a middleware handler that changes the default request body size limit.
|
||||
//
|
||||
// If limitBytes <= 0, no limit is applied.
|
||||
//
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ package apis
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -124,8 +125,8 @@ var DefaultCORSConfig = CORSConfig{
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}
|
||||
|
||||
// CORSWithConfig returns a CORS middleware with config.
|
||||
func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
// CORS returns a CORS middleware.
|
||||
func CORS(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
// Defaults
|
||||
if len(config.AllowOrigins) == 0 {
|
||||
config.AllowOrigins = DefaultCORSConfig.AllowOrigins
|
||||
@@ -134,13 +135,26 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
config.AllowMethods = DefaultCORSConfig.AllowMethods
|
||||
}
|
||||
|
||||
allowOriginPatterns := []string{}
|
||||
allowOriginPatterns := make([]*regexp.Regexp, 0, len(config.AllowOrigins))
|
||||
for _, origin := range config.AllowOrigins {
|
||||
if origin == "*" {
|
||||
continue // "*" is handled differently and does not need regexp
|
||||
}
|
||||
|
||||
pattern := regexp.QuoteMeta(origin)
|
||||
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
|
||||
pattern = strings.ReplaceAll(pattern, "\\?", ".")
|
||||
pattern = "^" + pattern + "$"
|
||||
allowOriginPatterns = append(allowOriginPatterns, pattern)
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// This is to preserve previous behaviour - invalid patterns were just ignored.
|
||||
// If we would turn this to panic, users with invalid patterns
|
||||
// would have applications crashing in production due unrecovered panic.
|
||||
log.Println("invalid AllowOrigins pattern", origin)
|
||||
continue
|
||||
}
|
||||
allowOriginPatterns = append(allowOriginPatterns, re)
|
||||
}
|
||||
|
||||
allowMethods := strings.Join(config.AllowMethods, ",")
|
||||
@@ -210,7 +224,7 @@ func CORSWithConfig(config CORSConfig) *hook.Handler[*core.RequestEvent] {
|
||||
}
|
||||
if checkPatterns {
|
||||
for _, re := range allowOriginPatterns {
|
||||
if match, _ := regexp.MatchString(re, origin); match {
|
||||
if match := re.MatchString(origin); match {
|
||||
allowOrigin = origin
|
||||
break
|
||||
}
|
||||
@@ -272,6 +286,7 @@ func matchSubdomain(domain, pattern string) bool {
|
||||
if !matchScheme(domain, pattern) {
|
||||
return false
|
||||
}
|
||||
|
||||
didx := strings.Index(domain, "://")
|
||||
pidx := strings.Index(pattern, "://")
|
||||
if didx == -1 || pidx == -1 {
|
||||
@@ -307,5 +322,6 @@ func matchSubdomain(domain, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+20
-17
@@ -221,23 +221,26 @@ func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||
if w.wroteHeader {
|
||||
w.ResponseWriter.WriteHeader(w.code)
|
||||
}
|
||||
|
||||
rw := w.ResponseWriter
|
||||
for {
|
||||
switch rf := rw.(type) {
|
||||
case io.ReaderFrom:
|
||||
return rf.ReadFrom(r)
|
||||
case router.RWUnwrapper:
|
||||
rw = rf.Unwrap()
|
||||
default:
|
||||
return io.Copy(w.ResponseWriter, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Disable the implementation for now because in case the platform
|
||||
// supports the sendfile fast-path it won't run gzipResponseWriter.Write,
|
||||
// preventing compression on the fly.
|
||||
//
|
||||
// func (w *gzipResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||
// if w.wroteHeader {
|
||||
// w.ResponseWriter.WriteHeader(w.code)
|
||||
// }
|
||||
// rw := w.ResponseWriter
|
||||
// for {
|
||||
// switch rf := rw.(type) {
|
||||
// case io.ReaderFrom:
|
||||
// return rf.ReadFrom(r)
|
||||
// case router.RWUnwrapper:
|
||||
// rw = rf.Unwrap()
|
||||
// default:
|
||||
// return io.Copy(w.ResponseWriter, r)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func (w *gzipResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
|
||||
+109
-32
@@ -1,6 +1,8 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -32,9 +34,12 @@ func rateLimit() *hook.Handler[*core.RequestEvent] {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(defaultRateLimitLabels(e))
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(
|
||||
defaultRateLimitLabels(e),
|
||||
defaultRateLimitAudience(e)...,
|
||||
)
|
||||
if ok {
|
||||
err := checkRateLimit(e, e.Request.Pattern, rule)
|
||||
err := checkRateLimit(e, rule.Label+rule.Audience, rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -94,28 +99,76 @@ func checkCollectionRateLimit(e *core.RequestEvent, collection *core.Collection,
|
||||
}
|
||||
labels = append(labels, defaultRateLimitLabels(e)...)
|
||||
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels)
|
||||
rule, ok := e.App.Settings().RateLimits.FindRateLimitRule(labels, defaultRateLimitAudience(e)...)
|
||||
if ok {
|
||||
return checkRateLimit(e, rtId, rule)
|
||||
return checkRateLimit(e, rtId+rule.Audience, rule)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isIPInList checks if the specified IP is in a list of other individual IPs or subnets.
|
||||
func isIPInList(ipsOrSubnets []string, ip string) bool {
|
||||
if len(ipsOrSubnets) == 0 || ip == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalize
|
||||
searchAddr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, item := range ipsOrSubnets {
|
||||
// subnet?
|
||||
prefix, err := netip.ParsePrefix(item)
|
||||
if err == nil {
|
||||
if prefix.Contains(searchAddr) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// individual ip?
|
||||
addr, err := netip.ParseAddr(item)
|
||||
if err == nil {
|
||||
if addr == searchAddr {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// @todo consider exporting as RateLimit helper?
|
||||
// @todo consider exporting as helper?
|
||||
func checkRateLimit(e *core.RequestEvent, rtId string, rule core.RateLimitRule) error {
|
||||
switch rule.Audience {
|
||||
case core.RateLimitRuleAudienceAll:
|
||||
// valid for both guest and regular users
|
||||
case core.RateLimitRuleAudienceGuest:
|
||||
if e.Auth != nil {
|
||||
return nil
|
||||
}
|
||||
case core.RateLimitRuleAudienceAuth:
|
||||
if e.Auth == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
rateLimiters := e.App.Store().GetOrSet(rateLimitersStoreKey, func() any {
|
||||
return initRateLimitersStore(e.App)
|
||||
}).(*store.Store[*rateLimiter])
|
||||
}).(*store.Store[string, *rateLimiter])
|
||||
if rateLimiters == nil {
|
||||
e.App.Logger().Warn("Failed to retrieve app rate limiters store")
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -129,14 +182,27 @@ 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
|
||||
}
|
||||
|
||||
func skipRateLimit(e *core.RequestEvent) bool {
|
||||
return !e.App.Settings().RateLimits.Enabled || e.HasSuperuserAuth()
|
||||
return !e.App.Settings().RateLimits.Enabled ||
|
||||
e.HasSuperuserAuth() ||
|
||||
isIPInList(e.App.Settings().RateLimits.ExcludedIPs, e.RealIP())
|
||||
}
|
||||
|
||||
var defaultAuthAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceAuth}
|
||||
var defaultGuestAudience = []string{core.RateLimitRuleAudienceAll, core.RateLimitRuleAudienceGuest}
|
||||
|
||||
func defaultRateLimitAudience(e *core.RequestEvent) []string {
|
||||
if e.Auth != nil {
|
||||
return defaultAuthAudience
|
||||
}
|
||||
|
||||
return defaultGuestAudience
|
||||
}
|
||||
|
||||
func defaultRateLimitLabels(e *core.RequestEvent) []string {
|
||||
@@ -149,9 +215,9 @@ func destroyRateLimitersStore(app core.App) {
|
||||
app.Store().Remove(rateLimitersStoreKey)
|
||||
}
|
||||
|
||||
func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
|
||||
func initRateLimitersStore(app core.App) *store.Store[string, *rateLimiter] {
|
||||
app.Cron().Add(rateLimitersCronKey, "2 * * * *", func() { // offset a little since too many cleanup tasks execute at 00
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[*rateLimiter])
|
||||
limitersStore, ok := app.Store().Get(rateLimitersStoreKey).(*store.Store[string, *rateLimiter])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -176,7 +242,7 @@ func initRateLimitersStore(app core.App) *store.Store[*rateLimiter] {
|
||||
},
|
||||
})
|
||||
|
||||
return store.New[*rateLimiter](nil)
|
||||
return store.New[string, *rateLimiter](nil)
|
||||
}
|
||||
|
||||
func newRateLimiter(maxAllowed int, intervalInSec int64, minDeleteIntervalInSec int64) *rateLimiter {
|
||||
@@ -184,12 +250,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
|
||||
@@ -199,6 +265,15 @@ type rateLimiter struct {
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (rt *rateLimiter) getClient(key string) (*rateClient, bool) {
|
||||
rt.RLock()
|
||||
client, ok := rt.clients[key]
|
||||
rt.RUnlock()
|
||||
|
||||
return client, ok
|
||||
}
|
||||
|
||||
func (rt *rateLimiter) isAllowed(key string) bool {
|
||||
// lock only reads to minimize locks contention
|
||||
rt.RLock()
|
||||
@@ -210,7 +285,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()
|
||||
@@ -236,7 +311,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
|
||||
}
|
||||
@@ -245,52 +320,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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
@@ -31,6 +32,18 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
},
|
||||
{
|
||||
Label: "/rate/guest",
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
Audience: core.RateLimitRuleAudienceGuest,
|
||||
},
|
||||
{
|
||||
Label: "/rate/auth",
|
||||
MaxRequests: 1,
|
||||
Duration: 1,
|
||||
Audience: core.RateLimitRuleAudienceAuth,
|
||||
},
|
||||
}
|
||||
|
||||
pbRouter, err := apis.NewRouter(app)
|
||||
@@ -48,6 +61,12 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
pbRouter.GET("/rate/b", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "b")
|
||||
})
|
||||
pbRouter.GET("/rate/guest", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "guest")
|
||||
})
|
||||
pbRouter.GET("/rate/auth", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "auth")
|
||||
})
|
||||
|
||||
mux, err := pbRouter.BuildMux()
|
||||
if err != nil {
|
||||
@@ -56,41 +75,81 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
|
||||
scenarios := []struct {
|
||||
url string
|
||||
wait float64
|
||||
wait float64 // ms
|
||||
authenticated bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
{"/norate", 0, false, 200},
|
||||
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 1.1, 200},
|
||||
{"/rate/a", 0, 200},
|
||||
{"/rate/a", 0, 429},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 900, false, 200}, // (fixed window check) wait enough to ensure that it can't fit more than 2 requests in 1s
|
||||
{"/rate/a", 900, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 0, false, 429},
|
||||
{"/rate/a", 1000, false, 200},
|
||||
{"/rate/a", 0, false, 200},
|
||||
{"/rate/a", 0, false, 429},
|
||||
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 429},
|
||||
{"/rate/b", 1.1, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 200},
|
||||
{"/rate/b", 0, 429},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
{"/rate/b", 1000, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 200},
|
||||
{"/rate/b", 0, false, 429},
|
||||
|
||||
// "auth" with guest (should fallback to the /rate/ rule)
|
||||
{"/rate/auth", 0, false, 200},
|
||||
{"/rate/auth", 0, false, 200},
|
||||
{"/rate/auth", 0, false, 429},
|
||||
{"/rate/auth", 0, false, 429},
|
||||
|
||||
// "auth" rule with regular user (should match the /rate/auth rule)
|
||||
{"/rate/auth", 0, true, 200},
|
||||
{"/rate/auth", 0, true, 429},
|
||||
{"/rate/auth", 0, true, 429},
|
||||
|
||||
// "guest" with guest (should match the /rate/guest rule)
|
||||
{"/rate/guest", 0, false, 200},
|
||||
{"/rate/guest", 0, false, 429},
|
||||
{"/rate/guest", 0, false, 429},
|
||||
|
||||
// "guest" rule with regular user (should fallback to the /rate/ rule)
|
||||
{"/rate/guest", 1000, true, 200},
|
||||
{"/rate/guest", 0, true, 200},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
{"/rate/guest", 0, true, 429},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if s.authenticated {
|
||||
auth, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, err := auth.NewAuthToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
if s.wait > 0 {
|
||||
time.Sleep(time.Duration(s.wait) * time.Millisecond)
|
||||
}
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
@@ -101,3 +160,163 @@ func TestDefaultRateLimitMiddleware(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRateLimitMiddlewareSkipChecks(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.Settings().RateLimits.Enabled = true
|
||||
app.Settings().RateLimits.Rules = []core.RateLimitRule{
|
||||
{
|
||||
Label: "/rate",
|
||||
MaxRequests: 1,
|
||||
Duration: 5,
|
||||
},
|
||||
}
|
||||
|
||||
pbRouter, err := apis.NewRouter(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// just for the exclude tests - load the user IP from a query param
|
||||
pbRouter.Bind(&hook.Handler[*core.RequestEvent]{
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
testIp := e.Request.URL.Query().Get("testIP")
|
||||
if testIp != "" {
|
||||
e.Request.Header.Set("x-test-ip", testIp)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
})
|
||||
|
||||
pbRouter.GET("/rate", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test")
|
||||
})
|
||||
|
||||
mux, err := pbRouter.BuildMux()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
checkStatusCodes := func(t *testing.T, got []int, expected []int) {
|
||||
if len(expected) != len(got) {
|
||||
t.Fatalf("Expected status codes %v, got %v", expected, got)
|
||||
}
|
||||
|
||||
for i, item := range expected {
|
||||
if got[i] != item {
|
||||
t.Fatalf("Expected %d status code to be %d, got %d:\n%v", i, item, got[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("base check", func(t *testing.T) {
|
||||
app.Settings().RateLimits.Enabled = true
|
||||
|
||||
statusCodes := []int{}
|
||||
for range 3 {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/rate", nil)
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
statusCodes = append(statusCodes, result.StatusCode)
|
||||
}
|
||||
|
||||
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
|
||||
})
|
||||
|
||||
t.Run("disabled rate limiter", func(t *testing.T) {
|
||||
app.Settings().RateLimits.Enabled = false
|
||||
|
||||
statusCodes := []int{}
|
||||
for range 3 {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/rate", nil)
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
statusCodes = append(statusCodes, result.StatusCode)
|
||||
}
|
||||
|
||||
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
|
||||
})
|
||||
|
||||
t.Run("authenticated as superuser", func(t *testing.T) {
|
||||
app.Settings().RateLimits.Enabled = true
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, err := superuser.NewAuthToken()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
statusCodes := []int{}
|
||||
for range 3 {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/rate", nil)
|
||||
req.Header.Add("Authorization", token)
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
statusCodes = append(statusCodes, result.StatusCode)
|
||||
}
|
||||
|
||||
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
|
||||
})
|
||||
|
||||
t.Run("excludedIPs (different)", func(t *testing.T) {
|
||||
app.Settings().RateLimits.Enabled = true
|
||||
app.Settings().RateLimits.ExcludedIPs = []string{"10.0.0.0"}
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
statusCodes := []int{}
|
||||
for range 3 {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/rate", nil)
|
||||
req.Header.Set("x-test-ip", "127.0.0.1")
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
statusCodes = append(statusCodes, result.StatusCode)
|
||||
}
|
||||
|
||||
checkStatusCodes(t, statusCodes, []int{200, 429, 429})
|
||||
})
|
||||
|
||||
t.Run("excludedIPs (match)", func(t *testing.T) {
|
||||
app.Settings().RateLimits.Enabled = true
|
||||
app.Settings().RateLimits.ExcludedIPs = []string{"127.0.0.1"}
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
statusCodes := []int{}
|
||||
for range 3 {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/rate", nil)
|
||||
req.Header.Set("x-test-ip", "127.0.0.1")
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
result := rec.Result()
|
||||
|
||||
statusCodes = append(statusCodes, result.StatusCode)
|
||||
}
|
||||
|
||||
checkStatusCodes(t, statusCodes, []int{200, 200, 200})
|
||||
})
|
||||
}
|
||||
|
||||
+118
-98
@@ -75,7 +75,7 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: beforeTestFunc,
|
||||
ExpectedStatus: 400,
|
||||
@@ -148,7 +148,7 @@ func TestRequireAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJfcGJjXzMzMjM4NjYzMzkifQ.C8m3aRZNOxUDhMiuZuDTRIIjRl7wsOyzoxs8EjvKNgY",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6ImZpbGUiLCJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyJ9.Lupz541xRvrktwkrl55p5pPCF77T69ZRsohsIcb2dxc",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
@@ -197,7 +197,7 @@ func TestRequireAuth(t *testing.T) {
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
// superuser
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
@@ -214,7 +214,7 @@ func TestRequireAuth(t *testing.T) {
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
// superuser
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
@@ -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 {
|
||||
@@ -253,7 +269,7 @@ func TestRequireSuperuserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
@@ -285,7 +301,7 @@ func TestRequireSuperuserAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
@@ -302,95 +318,6 @@ func TestRequireSuperuserAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSuperuserAuthOnlyIfAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest (while having at least 1 existing superuser)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
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.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "guest (while having 0 existing superusers)",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
}).Bind(apis.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "expired/invalid token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
|
||||
},
|
||||
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.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid regular user token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "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.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid superuser auth token",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
},
|
||||
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.RequireSuperuserAuthOnlyIfAny())
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -413,7 +340,7 @@ func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test/4q1xlclmfloku33",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoxNjQwOTkxNjYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.a668tes0bS6FU-OOlXMoRrdd57a_oldIPd5b0Gv_RYw",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjE2NDA5OTE2NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.0pDcBPGDpL2Khh76ivlRi7ugiLBSYvasct3qpHV3rfs",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
|
||||
@@ -491,7 +418,7 @@ func TestRequireSuperuserOrOwnerAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test/4q1xlclmfloku33",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test/{id}", func(e *core.RequestEvent) error {
|
||||
@@ -609,7 +536,7 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test/_pb_users_auth_",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
e.Router.GET("/my/test/{collection}", func(e *core.RequestEvent) error {
|
||||
@@ -626,3 +553,96 @@ func TestRequireSameCollectionContextAuth(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperuserIPsWhitelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupWhitelist := func(superuserIPs ...string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// allow loading a mock IP from the test scenario
|
||||
app.Settings().TrustedProxy = core.TrustedProxyConfig{
|
||||
Headers: []string{"x-test-ip"},
|
||||
}
|
||||
|
||||
app.Settings().SuperuserIPs = superuserIPs
|
||||
|
||||
err := app.Save(app.Settings())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
e.Router.GET("/my/test", func(e *core.RequestEvent) error {
|
||||
return e.String(200, "test123")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest with non-matching IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.1"},
|
||||
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "regular user with non-matching IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"x-test-ip": "127.0.0.1",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.ZT3F0Z3iM-xbGgSG3LEKiEzHrPHr8t8IuHLZGGNuxLo",
|
||||
},
|
||||
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser with non-matching IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"x-test-ip": "127.0.0.1",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: setupWhitelist("0.0.0.0"),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser with matching IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"x-test-ip": "127.0.0.1",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: setupWhitelist("0.0.0.0", "127.0.0.1"),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser with no whitelisted IPs",
|
||||
Method: http.MethodGet,
|
||||
URL: "/my/test",
|
||||
Headers: map[string]string{
|
||||
"x-test-ip": "127.0.0.1",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: setupWhitelist(),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+220
-58
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -27,6 +28,9 @@ const clientsChunkSize = 150
|
||||
// RealtimeClientAuthKey is the name of the realtime client store key that holds its auth state.
|
||||
const RealtimeClientAuthKey = "auth"
|
||||
|
||||
// RealtimeClientIPKey is the name of the realtime client store key that holds the IP of the connected client.
|
||||
const RealtimeClientIPKey = "pbRealtimeClientIP"
|
||||
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
sub := rg.Group("/realtime")
|
||||
@@ -62,8 +66,12 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
|
||||
connectEvent := new(core.RealtimeConnectRequestEvent)
|
||||
connectEvent.RequestEvent = e
|
||||
connectEvent.Client = subscriptions.NewDefaultClient()
|
||||
connectEvent.IdleTimeout = 5 * time.Minute
|
||||
connectEvent.MaxTimeout = 30 * time.Minute
|
||||
connectEvent.Client = subscriptions.NewDefaultClient()
|
||||
|
||||
// could be used as an optional cross-reference check in other API endpoints
|
||||
connectEvent.Client.Set(RealtimeClientIPKey, e.RealIP())
|
||||
|
||||
return e.App.OnRealtimeConnectRequest().Trigger(connectEvent, func(ce *core.RealtimeConnectRequestEvent) error {
|
||||
// register new subscription client
|
||||
@@ -72,7 +80,7 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
e.App.SubscriptionsBroker().Unregister(ce.Client.Id())
|
||||
}()
|
||||
|
||||
ce.App.Logger().Debug("Realtime connection established.", slog.String("clientId", ce.Client.Id()))
|
||||
ce.App.Logger().Debug("Realtime connection established", slog.String("clientId", ce.Client.Id()))
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
connectMsgEvent := new(core.RealtimeMessageEvent)
|
||||
@@ -83,11 +91,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 {
|
||||
@@ -99,12 +106,19 @@ func realtimeConnect(e *core.RequestEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// start a max lifetime timer to prevent accumulating too much
|
||||
// connection resources and to allow the GC to run more regularly
|
||||
maxTimer := time.NewTimer(ce.MaxTimeout)
|
||||
defer maxTimer.Stop()
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleTimer := time.NewTimer(ce.IdleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-maxTimer.C:
|
||||
cancelRequest()
|
||||
case <-idleTimer.C:
|
||||
cancelRequest()
|
||||
case msg, ok := <-ce.Client.Channel():
|
||||
@@ -122,11 +136,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 {
|
||||
@@ -160,7 +173,10 @@ type realtimeSubscribeForm struct {
|
||||
func (form *realtimeSubscribeForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.ClientId, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Subscriptions, validation.Length(0, 1000)),
|
||||
validation.Field(&form.Subscriptions,
|
||||
validation.Length(0, 1000),
|
||||
validation.Each(validation.Length(0, 2500)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,6 +200,21 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
return e.NotFoundError("Missing or invalid client id.", err)
|
||||
}
|
||||
|
||||
// for just in case to prevent someone changing a guest subscription
|
||||
//
|
||||
// note1: this is an extra precaution against clientId bruteforce attempts
|
||||
// for installations allowing longer realtime connections duration
|
||||
//
|
||||
// note2: custom registered clients (aka. those without IP in the store)
|
||||
// are excluded from the check for backward compatibility
|
||||
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
|
||||
if clientIP != "" && clientIP != e.RealIP() {
|
||||
return e.BadRequestError(
|
||||
"Invalid realtime client.",
|
||||
errors.New("the subscription request IP doesn't match with the realtime client IP"),
|
||||
)
|
||||
}
|
||||
|
||||
// for now allow only guest->auth upgrades and any other auth change is forbidden
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil && !isSameAuth(clientAuth, e.Auth) {
|
||||
@@ -206,17 +237,24 @@ func realtimeSetSubscriptions(e *core.RequestEvent) error {
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
e.App.Logger().Debug(
|
||||
"Realtime subscriptions updated.",
|
||||
"Realtime subscriptions updated",
|
||||
slog.String("clientId", e.Client.Id()),
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// updateClientsAuth updates the existing clients auth record with the new one (matched by ID).
|
||||
func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
||||
// realtimeUpdateClientsAuth updates the auth state of all clients related to the provided authRecord.
|
||||
//
|
||||
// Realtime connections has short lifetime by design, but to minimize abuse
|
||||
// if the new record has a different tokenKey (e.g. in case of password reset)
|
||||
// the auth state of the related realtime connections is also cleared
|
||||
// (aka. they remain active but unauthenticated, allowing to reauthenicate with the next subscription).
|
||||
func realtimeUpdateClientsAuth(app core.App, authRecord *core.Record) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
@@ -226,9 +264,13 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
||||
for _, client := range chunk {
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil &&
|
||||
clientAuth.Id == newAuthRecord.Id &&
|
||||
clientAuth.Collection().Name == newAuthRecord.Collection().Name {
|
||||
client.Set(RealtimeClientAuthKey, newAuthRecord)
|
||||
clientAuth.Id == authRecord.Id &&
|
||||
clientAuth.Collection().Name == authRecord.Collection().Name {
|
||||
if clientAuth.TokenKey() != authRecord.TokenKey() {
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
} else {
|
||||
client.Set(RealtimeClientAuthKey, authRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +281,8 @@ func realtimeUpdateClientsAuth(app core.App, newAuthRecord *core.Record) error {
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
|
||||
func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
||||
// realtimeUnsetClientsAuthByRecordModelOrProxy unsets the auth state of all clients that have the provided auth model.
|
||||
func realtimeUnsetClientsAuthByRecordModelOrProxy(app core.App, authModel core.Model) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
@@ -252,7 +294,29 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
||||
if clientAuth != nil &&
|
||||
clientAuth.Id == authModel.PK() &&
|
||||
clientAuth.Collection().Name == authModel.TableName() {
|
||||
app.SubscriptionsBroker().Unregister(client.Id())
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetClientsAuthByCollection unsets the auth state of all authenticated clients related to the collection.
|
||||
func realtimeUnsetClientsAuthByCollection(app core.App, collection *core.Collection) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
for _, client := range chunk {
|
||||
clientAuth, _ := client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuth != nil && clientAuth.Collection().Name == collection.Name {
|
||||
client.Unset(RealtimeClientAuthKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +328,53 @@ func realtimeUnregisterClientsByAuth(app core.App, authModel core.Model) error {
|
||||
}
|
||||
|
||||
func bindRealtimeEvents(app core.App) {
|
||||
// reset the clients auth on collection secret change
|
||||
// (@todo with the future tracking of old collections data consider replacing with *AfterUpdateSuccess to account for transaction rollback)
|
||||
app.OnCollectionUpdate().Bind(&hook.Handler[*core.CollectionEvent]{
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
if !e.Collection.IsAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
cached, _ := e.App.FindCachedCollectionByNameOrId(e.Collection.Id)
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cached != nil && cached.AuthToken.Secret != e.Collection.AuthToken.Secret {
|
||||
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the changed auth collection",
|
||||
slog.String("collectionName", e.Collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Priority: -99,
|
||||
})
|
||||
|
||||
// unset the clients auth on auth collection delete
|
||||
app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
|
||||
Func: func(e *core.CollectionEvent) error {
|
||||
if e.Collection.IsAuth() {
|
||||
if err := realtimeUnsetClientsAuthByCollection(e.App, e.Collection); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the deleted auth collection",
|
||||
slog.String("collectionName", e.Collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
Priority: -99,
|
||||
})
|
||||
|
||||
// update the clients that has auth record association
|
||||
app.OnModelAfterUpdateSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
@@ -290,7 +401,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil && collection.IsAuth() {
|
||||
if err := realtimeUnregisterClientsByAuth(e.App, e.Model); err != nil {
|
||||
if err := realtimeUnsetClientsAuthByRecordModelOrProxy(e.App, e.Model); err != nil {
|
||||
app.Logger().Warn(
|
||||
"Failed to remove client(s) associated to the deleted auth model",
|
||||
slog.Any("id", e.Model.PK()),
|
||||
@@ -350,7 +461,10 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true)
|
||||
// note: use the outside scoped app instance for the access checks so that the API rules
|
||||
// are performed out of the delete transaction ensuring that they would still work even if
|
||||
// a cascade-deleted record's API rule relies on an already deleted parent record
|
||||
err := realtimeBroadcastRecord(e.App, "delete", record, true, app)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
@@ -369,14 +483,17 @@ func bindRealtimeEvents(app core.App) {
|
||||
// delete: broadcast
|
||||
app.OnModelAfterDeleteSuccess().Bind(&hook.Handler[*core.ModelEvent]{
|
||||
Func: func(e *core.ModelEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeBroadcastDryCachedRecord(e.App, "delete", record)
|
||||
// note: only ensure that it is a collection record
|
||||
// and don't use realtimeResolveRecord because in case of a
|
||||
// custom model it'll fail to resolve since the record is already deleted
|
||||
collection := realtimeResolveRecordCollection(e.App, e.Model)
|
||||
if collection != nil {
|
||||
err := realtimeBroadcastDryCacheKey(e.App, getDryCacheKey("delete", e.Model))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.Any("id", e.Model.PK()),
|
||||
slog.String("collectionName", collection.Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
@@ -392,7 +509,7 @@ func bindRealtimeEvents(app core.App) {
|
||||
Func: func(e *core.ModelErrorEvent) error {
|
||||
record := realtimeResolveRecord(e.App, e.Model, "")
|
||||
if record != nil {
|
||||
err := realtimeUnsetDryCachedRecord(e.App, "delete", record)
|
||||
err := realtimeUnsetDryCacheKey(e.App, getDryCacheKey("delete", record))
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"Failed to cleanup after broadcast record delete failure",
|
||||
@@ -412,7 +529,14 @@ func bindRealtimeEvents(app core.App) {
|
||||
// resolveRecord converts *if possible* the provided model interface to a Record.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecord(app core.App, model core.Model, optCollectionType string) *core.Record {
|
||||
record, _ := model.(*core.Record)
|
||||
var record *core.Record
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
record = m
|
||||
case core.RecordProxy:
|
||||
record = m.ProxyRecord()
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
if optCollectionType == "" || record.Collection().Type == optCollectionType {
|
||||
return record
|
||||
@@ -441,14 +565,20 @@ func realtimeResolveRecord(app core.App, model core.Model, optCollectionType str
|
||||
// realtimeResolveRecordCollection extracts *if possible* the Collection model from the provided model interface.
|
||||
// This is usually helpful if the provided model is a custom Record model struct.
|
||||
func realtimeResolveRecordCollection(app core.App, model core.Model) (collection *core.Collection) {
|
||||
if record, ok := model.(*core.Record); ok {
|
||||
collection = record.Collection()
|
||||
} else {
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
collection, _ = app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
switch m := model.(type) {
|
||||
case *core.Record:
|
||||
return m.Collection()
|
||||
case core.RecordProxy:
|
||||
return m.ProxyRecord().Collection()
|
||||
default:
|
||||
// check if it is custom Record model struct
|
||||
collection, err := app.FindCachedCollectionByNameOrId(model.TableName())
|
||||
if err == nil {
|
||||
return collection
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
@@ -457,7 +587,11 @@ type recordData struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool) error {
|
||||
// Note: the optAccessCheckApp is there in case you want the access check
|
||||
// to be performed against different db app context (e.g. out of a transaction).
|
||||
// If set, it is expected that optAccessCheckApp instance is used for read-only operations to avoid deadlocks.
|
||||
// If not set, it fallbacks to app.
|
||||
func realtimeBroadcastRecord(app core.App, action string, record *core.Record, dryCache bool, optAccessCheckApp ...core.App) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("[broadcastRecord] Record collection not set")
|
||||
@@ -479,10 +613,15 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
(collection.Id + "?"): collection.ListRule,
|
||||
}
|
||||
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
dryCacheKey := getDryCacheKey(action, record)
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
accessCheckApp := app
|
||||
if len(optAccessCheckApp) > 0 {
|
||||
accessCheckApp = optAccessCheckApp[0]
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
group.Go(func() error {
|
||||
var clientAuth *core.Record
|
||||
@@ -499,10 +638,6 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
clientAuth, _ = client.Get(RealtimeClientAuthKey).(*core.Record)
|
||||
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// mock request data
|
||||
requestInfo := &core.RequestInfo{
|
||||
Context: core.RequestInfoContextRealtime,
|
||||
@@ -512,10 +647,28 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
Auth: clientAuth,
|
||||
}
|
||||
|
||||
if !realtimeCanAccessRecord(app, cleanRecord, requestInfo, rule) {
|
||||
if !realtimeCanAccessRecord(accessCheckApp, record, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
// create a clean record copy without expand and unknown fields because we don't know yet
|
||||
// which exact fields the client subscription requested or has permissions to access
|
||||
cleanRecord := record.Fresh()
|
||||
|
||||
// -------------------------------------------
|
||||
// @todo consider with the refactoring whether
|
||||
// the default enriching used by the regular APIs
|
||||
// can be reused here too to avoid eventual future
|
||||
// discrepencies in the record event data
|
||||
//
|
||||
// https://github.com/pocketbase/pocketbase/issues/7721
|
||||
// -------------------------------------------
|
||||
|
||||
// enable hidden fields for superuser subscribers
|
||||
if requestInfo.HasSuperuserAuth() {
|
||||
cleanRecord.Unhide(collection.Fields.FieldNames()...)
|
||||
}
|
||||
|
||||
// trigger the enrich hooks
|
||||
enrichErr := triggerRecordEnrichHooks(app, requestInfo, []*core.Record{cleanRecord}, func() error {
|
||||
// apply expand
|
||||
@@ -538,7 +691,7 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
// for auth owner, superuser or manager
|
||||
if collection.IsAuth() {
|
||||
if isSameAuth(clientAuth, cleanRecord) ||
|
||||
realtimeCanAccessRecord(app, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
realtimeCanAccessRecord(accessCheckApp, cleanRecord, requestInfo, collection.ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
@@ -619,15 +772,13 @@ func realtimeBroadcastRecord(app core.App, action string, record *core.Record, d
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeBroadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeBroadcastDryCacheKey broadcasts the dry cached key related messages.
|
||||
func realtimeBroadcastDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -656,15 +807,13 @@ func realtimeBroadcastDryCachedRecord(app core.App, action string, record *core.
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
// realtimeUnsetDryCachedRecord removes the dry cached record related messages.
|
||||
func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Record) error {
|
||||
// realtimeUnsetDryCacheKey removes the dry cached key related messages.
|
||||
func realtimeUnsetDryCacheKey(app core.App, key string) error {
|
||||
chunks := app.SubscriptionsBroker().ChunkedClients(clientsChunkSize)
|
||||
if len(chunks) == 0 {
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
key := action + "/" + record.Id
|
||||
|
||||
group := new(errgroup.Group)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -682,6 +831,15 @@ func realtimeUnsetDryCachedRecord(app core.App, action string, record *core.Reco
|
||||
return group.Wait()
|
||||
}
|
||||
|
||||
func getDryCacheKey(action string, model core.Model) string {
|
||||
pkStr, ok := model.PK().(string)
|
||||
if !ok {
|
||||
pkStr = fmt.Sprintf("%v", model.PK())
|
||||
}
|
||||
|
||||
return action + "/" + model.TableName() + "/" + pkStr
|
||||
}
|
||||
|
||||
func isSameAuth(authA, authB *core.Record) bool {
|
||||
if authA == nil {
|
||||
return authB == nil
|
||||
@@ -719,9 +877,9 @@ func realtimeCanAccessRecord(
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
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})
|
||||
|
||||
@@ -732,9 +890,13 @@ func realtimeCanAccessRecord(
|
||||
}
|
||||
|
||||
q.AndWhere(expr)
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
err = resolver.UpdateQuery(q)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = q.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
+737
-34
@@ -2,10 +2,13 @@ package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRealtimeConnect(t *testing.T) {
|
||||
@@ -22,6 +26,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`id:`,
|
||||
@@ -33,6 +38,17 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeMessageSend": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
app.OnRealtimeConnectRequest().BindFunc(func(e *core.RealtimeConnectRequestEvent) error {
|
||||
if ip, _ := e.Client.Get(apis.RealtimeClientIPKey).(string); ip != "127.0.0.2" {
|
||||
t.Fatalf("Expected IP %q, got %q", "127.0.0.2", ip)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
@@ -98,7 +114,8 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
|
||||
resetClient := func() {
|
||||
client.Unsubscribe()
|
||||
client.Set(apis.RealtimeClientAuthKey, nil)
|
||||
client.Unset(apis.RealtimeClientAuthKey)
|
||||
client.Unset(apis.RealtimeClientIPKey)
|
||||
}
|
||||
|
||||
validSubscriptionsLimit := make([]string, 1000)
|
||||
@@ -183,6 +200,74 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
resetClient()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client with invalid topic length",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/realtime",
|
||||
Body: strings.NewReader(`{
|
||||
"clientId": "` + client.Id() + `",
|
||||
"subscriptions": ["abc", "` + strings.Repeat("a", 2501) + `"]
|
||||
}`),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
resetClient()
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"subscriptions":{"1":{"code":"validation_length_too_long"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "existing client with different IP",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test"]}`),
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
client.Set(apis.RealtimeClientIPKey, "127.0.0.1")
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
resetClient()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client with valid topic length",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/realtime",
|
||||
Body: strings.NewReader(`{
|
||||
"clientId": "` + client.Id() + `",
|
||||
"subscriptions": ["abc", "` + strings.Repeat("a", 2500) + `"]
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRealtimeSubscribeRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if len(client.Subscriptions()) != 2 {
|
||||
t.Errorf("Expected %d subscriptions, got %d", 2, len(client.Subscriptions()))
|
||||
}
|
||||
if client.HasSubscription("test0") {
|
||||
t.Errorf("Expected old subscriptions to be replaced")
|
||||
}
|
||||
resetClient()
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing client - empty subscriptions",
|
||||
Method: http.MethodPost,
|
||||
@@ -238,7 +323,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
URL: "/api/realtime",
|
||||
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -377,28 +462,59 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
// mock delete event
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeDelete
|
||||
e.Context = context.Background()
|
||||
e.Model = authRecord
|
||||
e.Model = authRecord1
|
||||
|
||||
testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||
}
|
||||
|
||||
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
|
||||
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +523,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -418,25 +537,331 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord and change its email
|
||||
// refetch the authRecord and change its name
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
// mock update event
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeUpdate
|
||||
e.Context = context.Background()
|
||||
e.Model = authRecord2
|
||||
newName := "test_new_name"
|
||||
authRecord2.Set("name", newName)
|
||||
|
||||
testApp.OnModelAfterUpdateSuccess().Trigger(e)
|
||||
err = testApp.Save(authRecord2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
|
||||
if clientAuthRecord.Get("name") != newName {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", newName, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeRecordHiddenFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create temp collection with hidden fields
|
||||
testCollection := core.NewBaseCollection("test_realtime")
|
||||
testCollection.ListRule = types.Pointer("@request.auth.id != ''")
|
||||
testCollection.Fields.Add(
|
||||
&core.TextField{Name: "public"},
|
||||
&core.TextField{Name: "hidden", Hidden: true},
|
||||
)
|
||||
if err := testApp.Save(testCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testSubscription := testCollection.Name + "/*"
|
||||
|
||||
// register guest subscriber
|
||||
guestClient := subscriptions.NewDefaultClient()
|
||||
guestClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(guestClient)
|
||||
|
||||
// register regular user subscriber
|
||||
regular, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regularClient := subscriptions.NewDefaultClient()
|
||||
regularClient.Set(apis.RealtimeClientAuthKey, regular)
|
||||
regularClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(regularClient)
|
||||
|
||||
// register superuser subscriber
|
||||
superuser, err := testApp.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
superuserClient := subscriptions.NewDefaultClient()
|
||||
superuserClient.Set(apis.RealtimeClientAuthKey, superuser)
|
||||
superuserClient.Subscribe(testSubscription)
|
||||
testApp.SubscriptionsBroker().Register(superuserClient)
|
||||
|
||||
enrichCalls := map[string]int{}
|
||||
testApp.OnRecordEnrich(testCollection.Name).BindFunc(func(e *core.RecordEnrichEvent) error {
|
||||
var id string
|
||||
if e.RequestInfo.Auth != nil {
|
||||
id = e.RequestInfo.Auth.Id
|
||||
}
|
||||
enrichCalls[id]++
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
timeout := time.After(3 * time.Second)
|
||||
done := make(chan struct{})
|
||||
|
||||
// collect first received messages
|
||||
var regularMessageData, superuserMessageData string
|
||||
go func() {
|
||||
regularMessageData = string((<-regularClient.Channel()).Data)
|
||||
superuserMessageData = string((<-superuserClient.Channel()).Data)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
// broadcast create message
|
||||
testRecord := core.NewRecord(testCollection)
|
||||
testRecord.Set("public", "test1")
|
||||
testRecord.Set("hidden", "test2")
|
||||
if err := testApp.Save(testRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// wait for the events
|
||||
select {
|
||||
case <-timeout:
|
||||
t.Fatal("realtime test messages timeout")
|
||||
case <-done:
|
||||
// ready
|
||||
}
|
||||
|
||||
if total := len(enrichCalls); total != 2 {
|
||||
t.Fatalf("Expected %d enrich hook calls, got %d", 2, total)
|
||||
}
|
||||
|
||||
if total := enrichCalls[regular.Id]; total != 1 {
|
||||
t.Fatalf("Expected exactly 1 regular user enrich hook call, got %d", total)
|
||||
}
|
||||
|
||||
if total := enrichCalls[superuser.Id]; total != 1 {
|
||||
t.Fatalf("Expected exactly 1 superuser enrich hook call, got %d", total)
|
||||
}
|
||||
|
||||
// validate messages content
|
||||
scenarios := map[string]bool{
|
||||
"regular message public field should exist": strings.Contains(regularMessageData, `"public":`),
|
||||
"regular message hidden field should NOT exist": !strings.Contains(regularMessageData, `"hidden":`),
|
||||
"superuser message public field should exist": strings.Contains(superuserMessageData, `"public":`),
|
||||
"superuser message hidden field should exist": strings.Contains(superuserMessageData, `"hidden":`),
|
||||
}
|
||||
for name, valid := range scenarios {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if !valid {
|
||||
t.Fatal("Invalid realtime message expectation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnTokenKeyRefresh(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord and refresh its tokenKey
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authRecord2.RefreshTokenKey()
|
||||
|
||||
err = testApp.Save(authRecord2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
if clientAuthRecord != nil {
|
||||
t.Fatalf("Expected authRecord to be unset, got %q", clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnCollectionSecretChange(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
usersCollection, err := testApp.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
|
||||
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
|
||||
|
||||
clientMocks := map[*core.Record]subscriptions.Client{
|
||||
authRecord1: client1,
|
||||
authRecord2: client2,
|
||||
authRecord3: client3,
|
||||
}
|
||||
for _, client := range clientMocks {
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
}
|
||||
|
||||
// change the secret of the users collection (should trigger unset)
|
||||
usersCollection.AuthToken.Secret = strings.Repeat("a", 30)
|
||||
err = testApp.Save(usersCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// change something else of the clients collection (shouldn't trigger unset)
|
||||
clientsCollection.ListRule = nil
|
||||
err = testApp.Save(clientsCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectations := map[*core.Record]bool{
|
||||
// record -> unset
|
||||
authRecord1: true,
|
||||
authRecord2: true,
|
||||
authRecord3: false,
|
||||
}
|
||||
for record, expectedUnset := range expectations {
|
||||
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
unset := clientAuthRecord == nil
|
||||
if unset != expectedUnset {
|
||||
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAuthRecordUnsetOnCollectionDelete(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
usersCollection, err := testApp.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientsCollection, err := testApp.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail(usersCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
|
||||
authRecord3, err := testApp.FindAuthRecordByEmail(clientsCollection, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord3)
|
||||
|
||||
clientMocks := map[*core.Record]subscriptions.Client{
|
||||
authRecord1: client1,
|
||||
authRecord2: client2,
|
||||
authRecord3: client3,
|
||||
}
|
||||
for _, client := range clientMocks {
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
}
|
||||
|
||||
// mock users collection delete event to avoid triggering constraints check
|
||||
e := new(core.ModelEvent)
|
||||
e.App = testApp
|
||||
e.Type = core.ModelEventTypeDelete
|
||||
e.Context = context.Background()
|
||||
e.Model = usersCollection
|
||||
|
||||
err = testApp.OnModelAfterDeleteSuccess().Trigger(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectations := map[*core.Record]bool{
|
||||
// record -> unset
|
||||
authRecord1: true,
|
||||
authRecord2: true,
|
||||
authRecord3: false,
|
||||
}
|
||||
for record, expectedUnset := range expectations {
|
||||
clientAuthRecord, _ := clientMocks[record].Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||
unset := clientAuthRecord == nil
|
||||
if unset != expectedUnset {
|
||||
t.Fatalf("Expected unset state %v, got %v (%v)", expectedUnset, unset, clientAuthRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,19 +899,35 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.RealtimeClientAuthKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
authRecord1, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord2, err := testApp.FindAuthRecordByEmail("users", "test2@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Set(apis.RealtimeClientAuthKey, authRecord1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Set(apis.RealtimeClientAuthKey, authRecord2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp, "test@example.com")
|
||||
customUser, err := findCustomUserByEmail(testApp, authRecord1.Email())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -496,8 +937,20 @@ func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", total)
|
||||
if total := len(testApp.SubscriptionsBroker().Clients()); total != 3 {
|
||||
t.Fatalf("Expected %d subscription clients, found %d", 3, total)
|
||||
}
|
||||
|
||||
if auth := client1.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client1] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client2.Get(apis.RealtimeClientAuthKey); auth != nil {
|
||||
t.Fatalf("[client2] Expected the auth state to be unset, found %#v", auth)
|
||||
}
|
||||
|
||||
if auth := client3.Get(apis.RealtimeClientAuthKey); auth == nil || auth.(*core.Record).Id != authRecord2.Id {
|
||||
t.Fatalf("[client3] Expected the auth state to be left unchanged, found %#v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +959,10 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
_, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := testApp.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
@@ -534,3 +990,250 @@ func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ core.Model = (*CustomModelResolve)(nil)
|
||||
|
||||
type CustomModelResolve struct {
|
||||
core.BaseModel
|
||||
tableName string
|
||||
|
||||
Created string `db:"created"`
|
||||
}
|
||||
|
||||
func (m *CustomModelResolve) TableName() string {
|
||||
return m.tableName
|
||||
}
|
||||
|
||||
func TestRealtimeRecordResolve(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const testCollectionName = "realtime_test_collection"
|
||||
|
||||
testRecordId := core.GenerateDefaultRandomId()
|
||||
|
||||
client0 := subscriptions.NewDefaultClient()
|
||||
client0.Subscribe(testCollectionName + "/*")
|
||||
client0.Discard()
|
||||
// ---
|
||||
client1 := subscriptions.NewDefaultClient()
|
||||
client1.Subscribe(testCollectionName + "/*")
|
||||
// ---
|
||||
client2 := subscriptions.NewDefaultClient()
|
||||
client2.Subscribe(testCollectionName + "/" + testRecordId)
|
||||
// ---
|
||||
client3 := subscriptions.NewDefaultClient()
|
||||
client3.Subscribe("demo1/*")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
op func(testApp core.App) error
|
||||
expected map[string][]string // clientId -> [events]
|
||||
}{
|
||||
{
|
||||
"core.Record",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
r.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"core.RecordProxy",
|
||||
func(testApp core.App) error {
|
||||
c, err := testApp.FindCollectionByNameOrId(testCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r := core.NewRecord(c)
|
||||
|
||||
proxy := &struct {
|
||||
core.BaseRecordProxy
|
||||
}{}
|
||||
proxy.SetProxyRecord(r)
|
||||
proxy.Id = testRecordId
|
||||
|
||||
// create
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
err = testApp.Save(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(proxy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"custom model struct",
|
||||
func(testApp core.App) error {
|
||||
m := &CustomModelResolve{tableName: testCollectionName}
|
||||
m.Id = testRecordId
|
||||
|
||||
// create
|
||||
err := testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
m.Created = "123"
|
||||
err = testApp.Save(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete
|
||||
err = testApp.Delete(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
map[string][]string{
|
||||
client1.Id(): {"create", "update", "delete"},
|
||||
client2.Id(): {"create", "update", "delete"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
// init realtime handlers
|
||||
apis.NewRouter(testApp)
|
||||
|
||||
// create new test collection with public read access
|
||||
testCollection := core.NewBaseCollection(testCollectionName)
|
||||
testCollection.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true, OnUpdate: true})
|
||||
testCollection.ListRule = types.Pointer("")
|
||||
testCollection.ViewRule = types.Pointer("")
|
||||
err := testApp.Save(testCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testApp.SubscriptionsBroker().Register(client0)
|
||||
testApp.SubscriptionsBroker().Register(client1)
|
||||
testApp.SubscriptionsBroker().Register(client2)
|
||||
testApp.SubscriptionsBroker().Register(client3)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var notifications = map[string][]string{}
|
||||
|
||||
var mu sync.Mutex
|
||||
notify := func(clientId string, eventData []byte) {
|
||||
data := struct{ Action string }{}
|
||||
_ = json.Unmarshal(eventData, &data)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if notifications[clientId] == nil {
|
||||
notifications[clientId] = []string{}
|
||||
}
|
||||
notifications[clientId] = append(notifications[clientId], data.Action)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
timeout := time.After(250 * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-client0.Channel():
|
||||
if ok {
|
||||
notify(client0.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client1.Channel():
|
||||
if ok {
|
||||
notify(client1.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client2.Channel():
|
||||
if ok {
|
||||
notify(client2.Id(), e.Data)
|
||||
}
|
||||
case e, ok := <-client3.Channel():
|
||||
if ok {
|
||||
notify(client3.Id(), e.Data)
|
||||
}
|
||||
case <-timeout:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = s.op(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(s.expected) != len(notifications) {
|
||||
t.Fatalf("Expected %d notified clients, got %d:\n%v", len(s.expected), len(notifications), notifications)
|
||||
}
|
||||
|
||||
for id, events := range s.expected {
|
||||
if len(events) != len(notifications[id]) {
|
||||
t.Fatalf("[%s] Expected %d events, got %d:\n%v\n%v", id, len(events), len(notifications[id]), s.expected, notifications)
|
||||
}
|
||||
for _, event := range events {
|
||||
if !slices.Contains(notifications[id], event) {
|
||||
t.Fatalf("[%s] Missing expected event %q in %v", id, event, notifications[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,15 +38,16 @@ func recordConfirmEmailChange(e *core.RequestEvent) error {
|
||||
event.NewEmail = newEmail
|
||||
|
||||
return e.App.OnRecordConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeRequestEvent) error {
|
||||
authRecord.Set(core.FieldNameEmail, e.NewEmail)
|
||||
authRecord.Set(core.FieldNameVerified, true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
e.Record.SetEmail(e.NewEmail)
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,12 +102,6 @@ func (form *EmailChangeConfirmForm) parseToken() (*core.Record, string, error) {
|
||||
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
|
||||
}
|
||||
|
||||
// ensure that there aren't other users with the new email
|
||||
_, err := form.app.FindAuthRecordByEmail(form.collection, newEmail)
|
||||
if err == nil {
|
||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||
}
|
||||
|
||||
// verify that the token is not expired and its signature is valid
|
||||
authRecord, err := form.app.FindAuthRecordByToken(form.Token, core.TokenTypeEmailChange)
|
||||
if err != nil {
|
||||
@@ -117,5 +112,11 @@ func (form *EmailChangeConfirmForm) parseToken() (*core.Record, string, error) {
|
||||
return nil, "", validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
// check if there are other users with the new email
|
||||
_, err = form.app.FindAuthRecordByEmail(form.collection, newEmail)
|
||||
if err == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token_email", "The new email address is invalid.")
|
||||
}
|
||||
|
||||
return authRecord, newEmail, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -112,12 +111,51 @@ func TestRecordConfirmEmailChange(t *testing.T) {
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
// unverified->verified external auths removal
|
||||
"OnModelDelete": 2,
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatalf("Expected the user to be unverified before the confirmation")
|
||||
}
|
||||
|
||||
// ensure that there is at least one pre-existing OAuth2 link
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) == 0 {
|
||||
t.Fatal("Expected at least one external auths")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err := app.FindAuthRecordByEmail("users", "change@example.com")
|
||||
user, err := app.FindAuthRecordByEmail("users", "change@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Expected to find user with email %q, got error: %v", "change@example.com", err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to be verified after the confirmation")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -136,7 +174,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 +183,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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestRecordRequestEmailChange(t *testing.T) {
|
||||
URL: "/api/collections/users/request-email-change",
|
||||
Body: strings.NewReader(`{"newEmail":"change@example.com"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -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)
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -71,7 +71,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{"duration":-1}`),
|
||||
ExpectedStatus: 400,
|
||||
@@ -86,7 +86,7 @@ func TestRecordAuthImpersonate(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/impersonate/4q1xlclmfloku33",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: strings.NewReader(`{"duration":100}`),
|
||||
ExpectedStatus: 200,
|
||||
|
||||
@@ -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":`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -32,12 +33,10 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
record, err := e.App.FindAuthRecordByEmail(collection, form.Email)
|
||||
if err != nil {
|
||||
// eagerly write a dummy 200 response as a very rudimentary user emails enumeration protection
|
||||
e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": core.GenerateDefaultRandomId(),
|
||||
})
|
||||
return fmt.Errorf("failed to fetch %s record with email %s: %w", collection.Name, form.Email, err)
|
||||
|
||||
// ignore not found errors to allow custom record find implementations
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordCreateOTPRequestEvent)
|
||||
@@ -46,7 +45,18 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
originalApp := e.App
|
||||
|
||||
return e.App.OnRecordRequestOTPRequest().Trigger(event, func(e *core.RecordCreateOTPRequestEvent) error {
|
||||
if e.Record == nil {
|
||||
// write a dummy 200 response as a very rudimentary emails enumeration "protection"
|
||||
e.JSON(http.StatusOK, map[string]string{
|
||||
"otpId": core.GenerateDefaultRandomId(),
|
||||
})
|
||||
|
||||
return fmt.Errorf("missing or invalid %s OTP auth record with email %s", collection.Name, form.Email)
|
||||
}
|
||||
|
||||
var otp *core.OTP
|
||||
|
||||
// limit the new OTP creations for a single user
|
||||
@@ -90,17 +100,16 @@ func recordRequestOTP(e *core.RequestEvent) error {
|
||||
// send OTP email
|
||||
// (in the background as a very basic timing attacks and emails enumeration protection)
|
||||
// ---
|
||||
app := e.App
|
||||
routine.FireAndForget(func() {
|
||||
err = mails.SendRecordOTP(app, e.Record, otp.Id, e.Password)
|
||||
err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
|
||||
if err != nil {
|
||||
app.Logger().Error("Failed to send OTP email", "error", errors.Join(err, e.App.Delete(otp)))
|
||||
originalApp.Logger().Error("Failed to send OTP email", "error", errors.Join(err, originalApp.Delete(otp)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"otpId":"`, // some fake random generated string
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordRequestOTPRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 0 {
|
||||
t.Fatalf("Expected zero emails, got %d", app.TestMailer.TotalSend())
|
||||
@@ -137,6 +140,57 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 2, // + 1 for the OTP update after the email send
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 2,
|
||||
// OTP update
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
|
||||
}
|
||||
|
||||
// ensure that sentTo is set
|
||||
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
|
||||
if err != nil || len(otps) != 1 {
|
||||
t.Fatalf("Expected to find 1 OTP with sentTo %q, found %d", "test@example.com", len(otps))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "existing auth record with intercepted email (with < 9 non-expired)",
|
||||
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) {
|
||||
// prevent email sent
|
||||
app.OnMailerRecordOTPSend("users").BindFunc(func(e *core.MailerRecordEvent) error {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"otpId":"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"otpId":"otp_`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordRequestOTPRequest": 1,
|
||||
"OnMailerRecordOTPSend": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
@@ -144,8 +198,14 @@ func TestRecordRequestOTP(t *testing.T) {
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
t.Fatalf("Expected 1 email, got %d", app.TestMailer.TotalSend())
|
||||
if app.TestMailer.TotalSend() != 0 {
|
||||
t.Fatalf("Expected 0 emails, got %d", app.TestMailer.TotalSend())
|
||||
}
|
||||
|
||||
// ensure that there is no OTP with user email as sentTo
|
||||
otps, err := app.FindRecordsByFilter(core.CollectionNameOTPs, "sentTo='test@example.com'", "", 0, 0)
|
||||
if err != nil || len(otps) != 0 {
|
||||
t.Fatalf("Expected to find 0 OTPs with sentTo %q, found %d", "test@example.com", len(otps))
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -187,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
|
||||
// -----------------------------------------------------------
|
||||
|
||||
@@ -47,14 +47,16 @@ func recordConfirmPasswordReset(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
err = form.app.Save(authRecord)
|
||||
err = e.App.Save(authRecord)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to set new password.", err))
|
||||
}
|
||||
|
||||
form.app.Store().Remove(getPasswordResetResendKey(authRecord))
|
||||
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"
|
||||
@@ -115,11 +114,18 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordValidate": 1,
|
||||
// ---
|
||||
"OnModelDelete": 2, // pre-existing OAuth2 links
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
@@ -152,6 +158,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
if !user.ValidatePassword("1234567!") {
|
||||
t.Fatal("Password wasn't changed")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -186,11 +201,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
t.Fatal("Expected the user to be unverified")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually change the email to check whether the verified state will be updated
|
||||
user.SetEmail("test_update@example.com")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to update user test email: %v", err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err := app.FindAuthRecordByToken(
|
||||
@@ -213,6 +237,15 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
if !user.ValidatePassword("1234567!") {
|
||||
t.Fatal("Password wasn't changed")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 were NOT deleted
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) != 2 {
|
||||
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -243,11 +276,20 @@ func TestRecordConfirmPasswordReset(t *testing.T) {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// ensure that the user is already verified
|
||||
user.SetVerified(true)
|
||||
if err := app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to update user verified state")
|
||||
}
|
||||
|
||||
// resave with the old token key since the verified change above
|
||||
// would refresh it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
_, err := app.FindAuthRecordByToken(
|
||||
@@ -273,7 +315,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(`{
|
||||
@@ -283,15 +325,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"
|
||||
|
||||
@@ -26,7 +25,7 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-refresh",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -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,26 @@ 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)
|
||||
// similar to the OTP auth, we enforce an extra password reset
|
||||
// guard as this way is less prone to pre-hijacking attacks
|
||||
// in case the password auth is eventually enabled later
|
||||
if !e.Record.Collection().PasswordAuth.Enabled {
|
||||
e.Record.SetRandomPassword()
|
||||
}
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while saving the verified state.", err))
|
||||
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"
|
||||
@@ -106,6 +105,130 @@ func TestRecordConfirmVerification(t *testing.T) {
|
||||
"OnRecordValidate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// unverified->verified external auths removal
|
||||
"OnModelDelete": 2,
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatal("Expected the user to be unverified before the confirmation")
|
||||
}
|
||||
|
||||
// ensure that there is at least one pre-existing OAuth2 link
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) == 0 {
|
||||
t.Fatal("Expected at least one external auths")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to be verified after the confirmation")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token (disabled password auth)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MjUyNDYwNDQ2MSwidHlwZSI6InZlcmlmaWNhdGlvbiIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9.SetHpu2H-x-q4TIUz-xiQjwi7MNwLCLvSs4O0hUSp0E"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordConfirmVerificationRequest": 1,
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordValidate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// unverified->verified external auths removal
|
||||
"OnModelDelete": 2,
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user.Collection().PasswordAuth.Enabled = false
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatal("Expected the user to be unverified before the confirmation")
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatal("Expected password to be valid")
|
||||
}
|
||||
|
||||
// ensure that there is at least one pre-existing OAuth2 link
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) == 0 {
|
||||
t.Fatal("Expected at least one external auths")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to be verified after the confirmation")
|
||||
}
|
||||
|
||||
if user.ValidatePassword("1234567890") {
|
||||
t.Fatal("Expected the user password to be reset")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -144,7 +267,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 +275,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
|
||||
// -----------------------------------------------------------
|
||||
|
||||
+191
-41
@@ -1,13 +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"
|
||||
@@ -16,7 +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/security"
|
||||
"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
|
||||
@@ -96,6 +118,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 relation check.", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil && externalAuthRel != nil:
|
||||
authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
|
||||
@@ -107,7 +133,10 @@ func recordAuthWithOAuth2(e *core.RequestEvent) error {
|
||||
authRecord = fallbackAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, _ = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return e.InternalServerError("Failed OAuth2 auth record check.", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -127,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)
|
||||
})
|
||||
@@ -168,7 +201,7 @@ type recordOAuth2LoginForm struct {
|
||||
|
||||
func (form *recordOAuth2LoginForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.RedirectURL, validation.Required),
|
||||
)
|
||||
@@ -179,26 +212,48 @@ func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
|
||||
|
||||
_, ok := form.collection.OAuth2.GetProviderConfig(name)
|
||||
if !ok {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("Provider with name %q is missing or is not enabled.", name)).
|
||||
return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
|
||||
SetParams(map[string]any{"name": name})
|
||||
}
|
||||
|
||||
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 {
|
||||
// ensure that username is unique
|
||||
checkUnique := dbutils.HasSingleColumnUniqueIndex(collection.OAuth2.MappedFields.Username, collection.Indexes)
|
||||
if checkUnique {
|
||||
if _, err := txApp.FindFirstRecordByData(collection, collection.OAuth2.MappedFields.Username, username); err == nil {
|
||||
return false // already exist
|
||||
}
|
||||
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)
|
||||
txtField, _ := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username).(*core.TextField)
|
||||
if txtField, ok := field.(*core.TextField); ok && txtField.ValidatePlainValue(username) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return txtField != nil && txtField.ValidatePlainValue(username) == nil
|
||||
// ensure that username is unique
|
||||
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("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
|
||||
} else {
|
||||
expr = dbx.HashExp{colName: username}
|
||||
}
|
||||
|
||||
var exists int
|
||||
_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
|
||||
if exists > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
|
||||
@@ -215,12 +270,10 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload = map[string]any{}
|
||||
}
|
||||
|
||||
payload[core.FieldNameEmail] = e.OAuth2User.Email
|
||||
|
||||
// set a random password if none is set
|
||||
if v, _ := payload[core.FieldNamePassword].(string); v == "" {
|
||||
payload[core.FieldNamePassword] = security.RandomString(30)
|
||||
payload[core.FieldNamePassword+"Confirm"] = payload[core.FieldNamePassword]
|
||||
// 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)
|
||||
@@ -238,7 +291,7 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
|
||||
}
|
||||
if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
|
||||
// no existing OAuth2 mapping
|
||||
// no explicit avatar payload value and existing OAuth2 mapping
|
||||
e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
|
||||
// non-empty OAuth2 avatar url
|
||||
e.OAuth2User.AvatarURL != "" {
|
||||
@@ -246,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()))
|
||||
@@ -282,26 +338,43 @@ func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *cor
|
||||
e.Auth.Id == e.Record.Id &&
|
||||
e.Auth.Collection().Id == e.Record.Collection().Id
|
||||
|
||||
// set random password for users with unverified email
|
||||
// (this is in case a malicious actor has registered previously with the user email)
|
||||
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
|
||||
e.Record.SetPassword(security.RandomString(30))
|
||||
// prevent pre-hijacking with password auth
|
||||
//
|
||||
// reset the unverified user password in case the record was precreated by a malicious actor
|
||||
if !isLoggedAuthRecord && !e.Record.Verified() {
|
||||
needUpdate = true
|
||||
e.Record.SetRandomPassword()
|
||||
}
|
||||
|
||||
// prevent pre-hijacking with different OAuth2 provider
|
||||
//
|
||||
// delete all other previous OAuth2 record links for the cases
|
||||
// when the user was precreated by malicious OAuth2 auth with custom payload data
|
||||
//
|
||||
// while this would be also done automatically on unverified -> verified upgrade,
|
||||
// doing it manually here ensures that a single unverified record could have
|
||||
// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
|
||||
if !e.Record.Verified() {
|
||||
err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
optExternalAuth = nil // clear to allow recreate below
|
||||
}
|
||||
|
||||
// update the existing auth record empty email if the data.OAuth2User has one
|
||||
// (this is in case previously the auth record was created
|
||||
// with an OAuth2 provider that didn't return an email address)
|
||||
if e.Record.Email() == "" && e.OAuth2User.Email != "" {
|
||||
e.Record.SetEmail(e.OAuth2User.Email)
|
||||
needUpdate = true
|
||||
e.Record.SetEmail(e.OAuth2User.Email)
|
||||
}
|
||||
|
||||
// update the existing auth record verified state
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
|
||||
if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
|
||||
e.Record.SetVerified(true)
|
||||
needUpdate = true
|
||||
e.Record.SetVerified(true)
|
||||
}
|
||||
|
||||
if needUpdate {
|
||||
@@ -335,27 +408,104 @@ func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2R
|
||||
Body: payload,
|
||||
}
|
||||
|
||||
response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, nil)
|
||||
var createdRecord *core.Record
|
||||
response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, func(data any) error {
|
||||
createdRecord, _ = data.(*core.Record)
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.Status != http.StatusOK {
|
||||
if response.Status != http.StatusOK || createdRecord == nil {
|
||||
return nil, errors.New("failed to create OAuth2 auth record")
|
||||
}
|
||||
|
||||
recordResponse := struct {
|
||||
Id string `json:"id"`
|
||||
}{}
|
||||
return createdRecord, nil
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(response.Body)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(raw, &recordResponse); err != nil {
|
||||
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 txApp.FindRecordById(e.Collection, recordResponse.Id)
|
||||
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,44 @@ 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)
|
||||
|
||||
// additional check to minimize the risk of XSRF attack vectors
|
||||
//
|
||||
// note: custom registered clients (aka. those without IP in the store)
|
||||
// are excluded from the check for backward compatibility
|
||||
clientIP, _ := client.Get(RealtimeClientIPKey).(string)
|
||||
if clientIP != "" && clientIP != e.RealIP() {
|
||||
e.App.Logger().Debug("The client IP that completed the authentication is different from the one that initialized the OAuth2 realtime connection")
|
||||
return failureRedirect(e)
|
||||
}
|
||||
|
||||
// temporary store the Apple user's name so that it can be later retrieved with the authWithOAuth2 call
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/7090)
|
||||
if data.AppleUser != "" && data.Error == "" && data.Code != "" {
|
||||
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 +94,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,10 +3,12 @@ package apis_test
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
@@ -15,9 +17,9 @@ import (
|
||||
func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
|
||||
clientStubs := make([]map[string]subscriptions.Client, 0, 11)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := 0; i < 11; i++ {
|
||||
c1 := subscriptions.NewDefaultClient()
|
||||
|
||||
c2 := subscriptions.NewDefaultClient()
|
||||
@@ -56,18 +58,22 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't exit because it is usually called as part of a separate goroutine
|
||||
checkClientMessages := func(t testing.TB, clientId string, msg subscriptions.Message, expectedMessages map[string][]string) {
|
||||
if len(expectedMessages[clientId]) == 0 {
|
||||
t.Fatalf("Unexpected client %q message, got %s:\n%s", clientId, msg.Name, msg.Data)
|
||||
t.Errorf("Unexpected client %q message, got %q:\n%q", clientId, msg.Name, msg.Data)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Name != "@oauth2" {
|
||||
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
t.Errorf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
return
|
||||
}
|
||||
|
||||
for _, txt := range expectedMessages[clientId] {
|
||||
if !strings.Contains(string(msg.Data), txt) {
|
||||
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
|
||||
t.Errorf("Failed to find %q in \n%s", txt, msg.Data)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,19 +97,37 @@ func TestRecordAuthWithOAuth2Redirect(t *testing.T) {
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-clients["c1"].Channel():
|
||||
checkClientMessages(t, "c1", msg, expectedMessages)
|
||||
case msg := <-clients["c2"].Channel():
|
||||
checkClientMessages(t, "c2", msg, expectedMessages)
|
||||
case msg := <-clients["c3"].Channel():
|
||||
checkClientMessages(t, "c3", msg, expectedMessages)
|
||||
case msg := <-clients["c4"].Channel():
|
||||
checkClientMessages(t, "c4", msg, expectedMessages)
|
||||
case msg := <-clients["c5"].Channel():
|
||||
checkClientMessages(t, "c5", msg, expectedMessages)
|
||||
case msg, ok := <-clients["c1"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c1", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c1 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c2"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c2", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c2 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c3"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c3", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c3 closed channel")
|
||||
}
|
||||
case msg, ok := <-clients["c4"].Channel():
|
||||
if ok {
|
||||
checkClientMessages(t, "c4", msg, expectedMessages)
|
||||
} else {
|
||||
t.Errorf("Unexpected c4 closed channel")
|
||||
}
|
||||
case _, ok := <-clients["c5"].Channel():
|
||||
if ok {
|
||||
t.Errorf("Expected c5 channel to be closed")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
for _, c := range clients {
|
||||
close(c.Channel())
|
||||
c.Discard()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -244,6 +268,96 @@ 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client with different IP",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/oauth2-redirect?code=123&state=" + clientStubs[10]["c3"].Id(),
|
||||
Headers: map[string]string{"x-test-ip": "127.0.0.2"},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
clientStubs[10]["c3"].Set(apis.RealtimeClientIPKey, "127.0.0.1")
|
||||
|
||||
beforeTestFunc(clientStubs[10], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[10]["c3"].Id(), `"code":"123"`},
|
||||
})(t, app, e)
|
||||
},
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -44,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{
|
||||
{
|
||||
@@ -174,6 +177,34 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if err := app.Save(ea); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure that there is at least one other external auth different than test
|
||||
// so that later we can verify that it was deleted
|
||||
var hasAtLeastOneOtherEA = false
|
||||
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
|
||||
for _, rel := range externalAuths {
|
||||
if rel.Id != ea.Id {
|
||||
hasAtLeastOneOtherEA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAtLeastOneOtherEA {
|
||||
t.Fatal("Expected at least one non-test external auth linked")
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -196,12 +227,12 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// ---
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnModelCreate": 2, // user + recreated external auth
|
||||
"OnModelCreateExecute": 2,
|
||||
"OnModelAfterCreateSuccess": 2,
|
||||
"OnRecordCreate": 2,
|
||||
"OnRecordCreateExecute": 2,
|
||||
"OnRecordAfterCreateSuccess": 2,
|
||||
// ---
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
@@ -210,8 +241,15 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelValidate": 2, // create + update
|
||||
"OnRecordValidate": 2,
|
||||
"OnModelDelete": 3, // pre-existing external auths
|
||||
"OnModelDeleteExecute": 3,
|
||||
"OnModelAfterDeleteSuccess": 3,
|
||||
"OnRecordDelete": 3,
|
||||
"OnRecordDeleteExecute": 3,
|
||||
"OnRecordAfterDeleteSuccess": 3,
|
||||
// ---
|
||||
"OnModelValidate": 3, // user create/update + recreated external auth
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
@@ -231,6 +269,24 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
|
||||
}
|
||||
|
||||
// ensure that other linked external auths have been deleted
|
||||
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
|
||||
if len(externalAuths) != 1 {
|
||||
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
|
||||
}
|
||||
if provider := externalAuths[0].Provider(); provider != "test" {
|
||||
t.Fatalf("Expected %q external auth, got %q", "test", provider)
|
||||
}
|
||||
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
|
||||
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
|
||||
}
|
||||
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
|
||||
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
|
||||
}
|
||||
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
|
||||
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -292,6 +348,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test2@example.com"`,
|
||||
`"id":"oap640cot4yru2s"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -325,7 +382,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected old password %q to be valid", "1234567890")
|
||||
t.Fatalf("Expected old password %q to remain valid", "1234567890")
|
||||
}
|
||||
|
||||
devices, err := app.FindAllAuthOriginsByRecord(user)
|
||||
@@ -335,7 +392,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "link by email",
|
||||
Name: "link by email (unverified user)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -358,6 +415,20 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
|
||||
// ensure that there is at least one other external auth different than test
|
||||
// so that later we can verify that it was deleted
|
||||
var hasAtLeastOneOtherEA = false
|
||||
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
|
||||
for _, rel := range externalAuths {
|
||||
if rel.Provider() != "test" {
|
||||
hasAtLeastOneOtherEA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAtLeastOneOtherEA {
|
||||
t.Fatal("Expected at least one non-test external auth linked")
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
@@ -383,6 +454,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -413,6 +485,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelDelete": 2, // pre-existing external auths
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
// ---
|
||||
"OnModelValidate": 3, // record + authOrigins + externalAuths
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
@@ -430,6 +509,145 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
|
||||
}
|
||||
|
||||
// ensure that other linked external auths have been deleted
|
||||
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
|
||||
if len(externalAuths) != 1 {
|
||||
t.Fatalf("Expected only 1 external auth to remain, got %d", len(externalAuths))
|
||||
}
|
||||
if provider := externalAuths[0].Provider(); provider != "test" {
|
||||
t.Fatalf("Expected %q external auth, got %q", "test", provider)
|
||||
}
|
||||
if providerId := externalAuths[0].ProviderId(); providerId != "test_id" {
|
||||
t.Fatalf("Expected %q providerId, got %q", "test_id", providerId)
|
||||
}
|
||||
if recordRef := externalAuths[0].RecordRef(); recordRef != user.Id {
|
||||
t.Fatalf("Expected %q recordRef, got %q", user.Id, recordRef)
|
||||
}
|
||||
if collectionRef := externalAuths[0].CollectionRef(); collectionRef != user.Collection().Id {
|
||||
t.Fatalf("Expected %q collectionRef, got %q", user.Collection().Id, collectionRef)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "link by email (verified user)",
|
||||
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", "test3@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected user %q to be verified", user.Email())
|
||||
}
|
||||
|
||||
// ensure that the old password works
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
AuthUser: &auth.AuthUser{Id: "test_id", Email: "test3@example.com"},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that there is at least one other external auth different than test
|
||||
// so that later we can verify that they are not deleted
|
||||
var hasAtLeastOneOtherEA = false
|
||||
externalAuths, _ := app.FindAllExternalAuthsByRecord(user)
|
||||
for _, rel := range externalAuths {
|
||||
if rel.Provider() != "test" {
|
||||
hasAtLeastOneOtherEA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAtLeastOneOtherEA {
|
||||
t.Fatal("Expected at least one non-test external auth linked")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test3@example.com"`,
|
||||
`"id":"bgs820n361vj1qd"`,
|
||||
`"id":"test_id"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOAuth2Request": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// ---
|
||||
"OnModelCreate": 2, // authOrigins + externalAuths
|
||||
"OnModelCreateExecute": 2,
|
||||
"OnModelAfterCreateSuccess": 2,
|
||||
"OnRecordCreate": 2,
|
||||
"OnRecordCreateExecute": 2,
|
||||
"OnRecordAfterCreateSuccess": 2,
|
||||
// ---
|
||||
"OnModelValidate": 2, // authOrigins + externalAuths
|
||||
"OnRecordValidate": 2,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test3@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected old password %q to remain valid", "1234567890")
|
||||
}
|
||||
|
||||
devices, err := app.FindAllAuthOriginsByRecord(user)
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected only 1 auth origin to be created, got %d (%v)", len(devices), err)
|
||||
}
|
||||
|
||||
var hasTestEA = false
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if len(externalAuths) <= 1 {
|
||||
t.Fatalf("Expected to have 2+ ExternalAuth records, got %d (%v)", len(externalAuths), err)
|
||||
}
|
||||
for _, rel := range externalAuths {
|
||||
if rel.Provider() == "test" {
|
||||
hasTestEA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTestEA {
|
||||
t.Fatal("Expected test external auth to be linked")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -488,6 +706,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -511,6 +730,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordCreateExecute": 2,
|
||||
"OnRecordAfterCreateSuccess": 2,
|
||||
// ---
|
||||
"OnModelDelete": 2, // pre-existing external auths
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
// ---
|
||||
"OnModelValidate": 2,
|
||||
"OnRecordValidate": 2,
|
||||
},
|
||||
@@ -521,7 +747,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q not to be changed", "1234567890")
|
||||
t.Fatalf("Expected old password %q to remain valid", "1234567890")
|
||||
}
|
||||
|
||||
devices, err := app.FindAllAuthOriginsByRecord(user)
|
||||
@@ -558,12 +784,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
t.Fatalf("Expected password %q to be valid", "1234567890")
|
||||
}
|
||||
|
||||
oldTokenKey := user.TokenKey()
|
||||
|
||||
// manually unset the user email
|
||||
user.SetEmail("")
|
||||
if err := app.Save(user); err != nil {
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave with the old token key since the email change above
|
||||
// would change it and will make the password token invalid
|
||||
user.SetTokenKey(oldTokenKey)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatalf("Failed to restore original user tokenKey: %v", err)
|
||||
}
|
||||
|
||||
// register the test provider
|
||||
auth.Providers["test"] = func() auth.Provider {
|
||||
return &oauth2MockProvider{
|
||||
@@ -592,6 +827,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test_oauth2@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -622,6 +858,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelDelete": 2, // pre-existing external auths
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
// ---
|
||||
"OnModelValidate": 3, // record + authOrigins + externalAuths
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
@@ -632,7 +875,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q not to be changed", "1234567890")
|
||||
t.Fatalf("Expected old password %q to remain valid", "1234567890")
|
||||
}
|
||||
|
||||
devices, err := app.FindAllAuthOriginsByRecord(user)
|
||||
@@ -697,6 +940,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":false`,
|
||||
`"email":"test@example.com"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"test_id"`,
|
||||
@@ -727,6 +971,13 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnModelDelete": 2, // pre-existing external auths
|
||||
"OnModelDeleteExecute": 2,
|
||||
"OnModelAfterDeleteSuccess": 2,
|
||||
"OnRecordDelete": 2,
|
||||
"OnRecordDeleteExecute": 2,
|
||||
"OnRecordAfterDeleteSuccess": 2,
|
||||
// ---
|
||||
"OnModelValidate": 3, // record + authOrigins + externalAuths
|
||||
"OnRecordValidate": 3,
|
||||
},
|
||||
@@ -737,7 +988,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
}
|
||||
|
||||
if !user.ValidatePassword("1234567890") {
|
||||
t.Fatalf("Expected password %q not to be changed", "1234567890")
|
||||
t.Fatalf("Expected old password %q to remain valid", "1234567890")
|
||||
}
|
||||
|
||||
devices, err := app.FindAllAuthOriginsByRecord(user)
|
||||
@@ -786,6 +1037,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"meta":{`,
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"id":"test_id"`,
|
||||
`"verified":true`,
|
||||
@@ -867,7 +1119,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
|
||||
},
|
||||
@@ -920,12 +1172,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,
|
||||
@@ -939,7 +1189,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(`{
|
||||
@@ -947,8 +1197,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"code":"123",
|
||||
"redirectURL": "https://example.com",
|
||||
"createData": {
|
||||
"email": "invalid",
|
||||
"email": "",
|
||||
"emailVisibility": true,
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
"name": "test_name",
|
||||
"username": "test_username",
|
||||
"rel": "0yxhwia2amd8gec"
|
||||
@@ -982,6 +1234,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":""`,
|
||||
`"emailVisibility":true`,
|
||||
`"name":"test_name"`,
|
||||
@@ -1018,6 +1271,105 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
"OnModelValidate": 4,
|
||||
"OnRecordValidate": 4,
|
||||
},
|
||||
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 (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)",
|
||||
@@ -1046,7 +1398,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"},
|
||||
}
|
||||
@@ -1071,13 +1423,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
|
||||
@@ -1153,6 +1597,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1189,7 +1634,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and avatarURL->non-file field)",
|
||||
Name: "creating user (with mapped OAuth2 fields, case-sensitive username and avatarURL->non-file field)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1209,9 +1654,9 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "oauth2_username",
|
||||
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"},
|
||||
}
|
||||
@@ -1235,9 +1680,10 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"username":"oauth2_username"`,
|
||||
`"username":"tESt2_username"`,
|
||||
`"name":"http://127.`,
|
||||
`"verified":true`,
|
||||
`"avatar":""`,
|
||||
@@ -1273,7 +1719,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated username)",
|
||||
Name: "creating user (with mapped OAuth2 fields and duplicated case-insensitive username)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-oauth2",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -1293,13 +1739,21 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
AuthUser: &auth.AuthUser{
|
||||
Id: "oauth2_id",
|
||||
Email: "oauth2@example.com",
|
||||
Username: "test2_username",
|
||||
Username: "tESt2_username",
|
||||
Name: "oauth2_name",
|
||||
},
|
||||
Token: &oauth2.Token{AccessToken: "abc"},
|
||||
}
|
||||
}
|
||||
|
||||
// make the username index case-insensitive to ensure that case-insensitive match is used
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(usersCol.Indexes, "username")
|
||||
if ok {
|
||||
index.Columns[0].Collate = "nocase"
|
||||
usersCol.RemoveIndex(index.IndexName)
|
||||
usersCol.Indexes = append(usersCol.Indexes, index.Build())
|
||||
}
|
||||
|
||||
// add the test provider in the collection
|
||||
usersCol.MFA.Enabled = false
|
||||
usersCol.OAuth2.Enabled = true
|
||||
@@ -1317,6 +1771,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1398,6 +1853,7 @@ func TestRecordAuthWithOAuth2(t *testing.T) {
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"isNew":true`,
|
||||
`"email":"oauth2@example.com"`,
|
||||
`"emailVisibility":false`,
|
||||
`"verified":true`,
|
||||
@@ -1434,6 +1890,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,8 +53,8 @@ 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 we enforce an extra rate limit rule to prevent enumerations
|
||||
err = checkRateLimit(e, "@pb_otp_"+event.OTP.Id+event.Record.Id, core.RateLimitRule{MaxRequests: 4, Duration: 180})
|
||||
// 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)
|
||||
}
|
||||
@@ -63,24 +65,39 @@ func recordAuthWithOTP(e *core.RequestEvent) error {
|
||||
// ---
|
||||
|
||||
return e.App.OnRecordAuthWithOTPRequest().Trigger(event, func(e *core.RecordAuthWithOTPRequestEvent) error {
|
||||
err = RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
otpId := e.OTP.Id
|
||||
otpSentTo := e.OTP.SentTo()
|
||||
|
||||
// eagerly delete the OTP to avoid unnecessary double delete model hook calls
|
||||
// triggered by the password change below
|
||||
err := e.App.Delete(e.OTP)
|
||||
if err != nil {
|
||||
return err
|
||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||
}
|
||||
|
||||
// try to delete the used otp
|
||||
if e.OTP != nil {
|
||||
err = e.App.Delete(e.OTP)
|
||||
if err != nil {
|
||||
e.App.Logger().Error("Failed to delete used OTP", "error", err, "otpId", e.OTP.Id)
|
||||
// update the user email verified state in case the OTP originate from an email address matching the current record one
|
||||
//
|
||||
// note: don't wait for success auth response (it could fail because of MFA) and because we already validated the OTP above
|
||||
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
|
||||
e.Record.SetVerified(true)
|
||||
|
||||
// this is technically not required but we enforce password
|
||||
// reset on verified upgrades in case the OTP is used on its own
|
||||
// since this makes it less error prone to pre-hijacking attacks
|
||||
if !e.Record.Collection().MFA.Enabled {
|
||||
e.Record.SetRandomPassword()
|
||||
}
|
||||
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
e.App.Logger().Error("Failed to update record verified state after successful OTP validation",
|
||||
"error", err,
|
||||
"otpId", otpId,
|
||||
"recordId", e.Record.Id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// note: we don't update the user verified state the same way as in the password reset confirmation
|
||||
// at the moment because it is not clear whether the otp confirmation came from the user email
|
||||
// (e.g. it could be from an sms or some other channel)
|
||||
|
||||
return nil
|
||||
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password",
|
||||
Name: "valid otp with valid password (enabled MFA)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -236,7 +236,7 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password (disabled MFA)",
|
||||
Name: "valid otp with valid password and empty sentTo (disabled MFA)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/users/auth-with-otp",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -249,8 +249,15 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensure that the user is unverified
|
||||
user.SetVerified(false)
|
||||
if err = app.Save(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// disable MFA
|
||||
user.Collection().MFA.Enabled = false
|
||||
if err := app.Save(user.Collection()); err != nil {
|
||||
if err = app.Save(user.Collection()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -262,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{
|
||||
@@ -297,6 +318,189 @@ func TestRecordAuthWithOTP(t *testing.T) {
|
||||
"OnRecordDeleteExecute": 1,
|
||||
"OnRecordAfterDeleteSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatal("Expected the user to remain unverified because sentTo != email")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 were NOT deleted
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) != 2 {
|
||||
t.Fatalf("Expected 2 external auths, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid otp with valid password and nonempty sentTo=email (disabled MFA)",
|
||||
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)
|
||||
}
|
||||
|
||||
// ensure that the user is unverified
|
||||
user.SetVerified(false)
|
||||
if err = app.Save(user); 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")
|
||||
otp.SetSentTo(user.Email())
|
||||
if err := app.Save(otp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// verify that there are at least one pre-existing OAuth2 link
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) == 0 {
|
||||
t.Fatal("Expected at least one external auth")
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
`"record":{`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
`"meta":`,
|
||||
// hidden fields
|
||||
`"tokenKey"`,
|
||||
`"password"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithOTPRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
// ---
|
||||
"OnModelValidate": 2, // +1 because of the verified user update
|
||||
// authOrigin create
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
// record OTP + 2 ExternalAuths delete
|
||||
"OnModelDelete": 3,
|
||||
"OnModelDeleteExecute": 3,
|
||||
"OnModelAfterDeleteSuccess": 3,
|
||||
// user verified update
|
||||
"OnModelUpdate": 1,
|
||||
"OnModelUpdateExecute": 1,
|
||||
"OnModelAfterUpdateSuccess": 1,
|
||||
// ---
|
||||
"OnRecordValidate": 2,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordDelete": 3,
|
||||
"OnRecordDeleteExecute": 3,
|
||||
"OnRecordAfterDeleteSuccess": 3,
|
||||
"OnRecordUpdate": 1,
|
||||
"OnRecordUpdateExecute": 1,
|
||||
"OnRecordAfterUpdateSuccess": 1,
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatal("Expected the user to be marked as verified")
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OTPs are cleared
|
||||
otps, err := app.FindAllOTPsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(otps) > 0 {
|
||||
t.Fatalf("Expected all OTPs to be cleared, found %d", len(otps))
|
||||
}
|
||||
|
||||
// ensure that all pre-existing OAuth2 links are cleared
|
||||
externalAuths, err := app.FindAllExternalAuthsByRecord(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(externalAuths) > 0 {
|
||||
t.Fatalf("Expected all external auths to be cleared, found %d", len(externalAuths))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "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
|
||||
@@ -387,11 +591,13 @@ func TestRecordAuthWithOTPManualRateLimiterCheck(t *testing.T) {
|
||||
}{
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "12345", 400},
|
||||
{otpAId, "123456", 429},
|
||||
{otpBId, "12345", 400},
|
||||
{otpBId, "123456", 200},
|
||||
{otpBId, "12345", 400},
|
||||
{otpBId, "12345", 400},
|
||||
{otpAId, "12345", 429},
|
||||
{otpAId, "123456", 429}, // reject even if it is correct
|
||||
{otpAId, "123456", 429},
|
||||
{otpBId, "123456", 429},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -3,10 +3,14 @@ package apis
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
@@ -28,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 = e.App.FindFirstRecordByData(collection.Id, form.IdentityField, form.Identity)
|
||||
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 = e.App.FindAuthRecordByEmail(collection.Id, 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 = e.App.FindFirstRecordByData(collection.Id, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
foundRecord, foundErr = findRecordByIdentityField(e.App, collection, name, form.Identity)
|
||||
if foundErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +85,11 @@ func recordAuthWithPassword(e *core.RequestEvent) error {
|
||||
|
||||
return e.App.OnRecordAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordRequestEvent) error {
|
||||
if e.Record == nil || !e.Record.ValidatePassword(e.Password) {
|
||||
// dummy password check to minimize enumeration side-channel attacks
|
||||
if e.Record == nil {
|
||||
dummyPasswordCheck(e.App, e.Collection)
|
||||
}
|
||||
|
||||
return e.BadRequestError("Failed to authenticate.", errors.New("invalid login credentials"))
|
||||
}
|
||||
|
||||
@@ -92,6 +112,53 @@ func (form *authWithPasswordForm) validate(collection *core.Collection) error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.IdentityField, validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...)),
|
||||
validation.Field(
|
||||
&form.IdentityField,
|
||||
validation.Length(1, 255),
|
||||
validation.In(list.ToInterfaceSlice(collection.PasswordAuth.IdentityFields)...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// dummy password check to minimize side-channel attacks
|
||||
// (performed with the collection configured field cost)
|
||||
func dummyPasswordCheck(app core.App, collection *core.Collection) {
|
||||
record := &core.Record{}
|
||||
|
||||
// find any random existing record
|
||||
err := app.RecordQuery(collection).Limit(1).One(record)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// the value and result doesn't matter, we just need a constant-time check
|
||||
_ = record.ValidatePassword("")
|
||||
}
|
||||
|
||||
func findRecordByIdentityField(app core.App, collection *core.Collection, field string, value any) (*core.Record, error) {
|
||||
if !slices.Contains(collection.PasswordAuth.IdentityFields, field) {
|
||||
return nil, errors.New("invalid identity field " + field)
|
||||
}
|
||||
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field)
|
||||
if !ok {
|
||||
return nil, errors.New("missing " + field + " unique index constraint")
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if strings.EqualFold(index.Columns[0].Collate, "nocase") {
|
||||
// case-insensitive search
|
||||
expr = dbx.NewExp("[["+field+"]] = {:identity} COLLATE NOCASE", dbx.Params{"identity": value})
|
||||
} else {
|
||||
expr = dbx.HashExp{field: value}
|
||||
}
|
||||
|
||||
record := &core.Record{}
|
||||
|
||||
err := app.RecordQuery(collection).AndWhere(expr).Limit(1).One(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
)
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
updateIdentityIndex := func(collectionIdOrName string, fieldCollateMap map[string]string) func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
return func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
collection, err := app.FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for column, collate := range fieldCollateMap {
|
||||
index, ok := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, column)
|
||||
if !ok {
|
||||
t.Fatalf("Missing unique identityField index for column %q", column)
|
||||
}
|
||||
|
||||
index.Columns[0].Collate = collate
|
||||
|
||||
collection.RemoveIndex(index.IndexName)
|
||||
collection.Indexes = append(collection.Indexes, index.Build())
|
||||
}
|
||||
|
||||
err = app.Save(collection)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update identityField index: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "disabled password auth",
|
||||
@@ -55,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(`{
|
||||
@@ -64,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",
|
||||
@@ -99,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"`,
|
||||
@@ -164,6 +212,73 @@ 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,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "created",
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"identityField":{"code":"validation_in_invalid"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "valid identity field and valid password with mismatched explicit identityField",
|
||||
Method: http.MethodPost,
|
||||
@@ -440,6 +555,141 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// case sensitivity checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "with explicit identityField (case-sensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "username",
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": ""}),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with explicit identityField (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identityField": "username",
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"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: "without explicit identityField and non-email field (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"Clients57772",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"username": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"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: "without explicit identityField and email field (case-insensitive)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"tESt@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: updateIdentityIndex("clients", map[string]string{"email": "nocase"}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"email":"test@example.com"`,
|
||||
`"username":"clients57772"`,
|
||||
`"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,
|
||||
},
|
||||
},
|
||||
|
||||
// rate limit checks
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
|
||||
+266
-99
@@ -1,17 +1,23 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
cryptoRand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
@@ -22,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 {
|
||||
@@ -53,23 +59,32 @@ func recordsList(e *core.RequestEvent) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fieldsResolver := core.NewRecordFieldResolver(
|
||||
e.App,
|
||||
collection,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by superusers
|
||||
requestInfo.HasSuperuserAuth(),
|
||||
)
|
||||
query := e.App.RecordQuery(collection)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(e.App.RecordQuery(collection))
|
||||
fieldsResolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true)
|
||||
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil && *collection.ListRule != "" {
|
||||
expr, err := search.FilterData(*collection.ListRule).BuildExpr(fieldsResolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
// will be applied by the search provider right before executing the query
|
||||
// fieldsResolver.UpdateQuery(query)
|
||||
}
|
||||
|
||||
// hidden fields are searchable only by superusers
|
||||
fieldsResolver.SetAllowHiddenFields(requestInfo.HasSuperuserAuth())
|
||||
|
||||
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 {
|
||||
return firstApiError(err, e.BadRequestError("", err))
|
||||
@@ -86,10 +101,46 @@ func recordsList(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.InternalServerError("Failed to enrich records", err))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
// Add a randomized throttle in case of too many empty search filter attempts.
|
||||
//
|
||||
// This is just for extra precaution since security researches raised concern regarding the possibility of eventual
|
||||
// timing attacks because the List API rule acts also as filter and executes in a single run with the client-side filters.
|
||||
// This is by design and it is an accepted trade off between performance, usability and correctness.
|
||||
//
|
||||
// While technically the below doesn't fully guarantee protection against filter timing attacks, in practice combined with the network latency it makes them even less feasible.
|
||||
// A properly configured rate limiter or individual fields Hidden checks are better suited if you are really concerned about eventual information disclosure by side-channel attacks.
|
||||
//
|
||||
// In all cases it doesn't really matter that much because it doesn't affect the builtin PocketBase security sensitive fields (e.g. password and tokenKey) since they
|
||||
// are not client-side filterable and in the few places where they need to be compared against an external value, a constant time check is used.
|
||||
if !e.HasSuperuserAuth() &&
|
||||
(collection.ListRule != nil && *collection.ListRule != "") &&
|
||||
(requestInfo.Query["filter"] != "") &&
|
||||
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(500)
|
||||
}
|
||||
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var listTimingRateLimitRule = core.RateLimitRule{MaxRequests: 3, Duration: 3}
|
||||
|
||||
func randomizedThrottle(softMax int64) {
|
||||
var timeout int64
|
||||
randRange, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(softMax))
|
||||
if err == nil {
|
||||
timeout = randRange.Int64()
|
||||
} else {
|
||||
timeout = softMax
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(timeout) * time.Millisecond)
|
||||
}
|
||||
|
||||
func recordView(e *core.RequestEvent) error {
|
||||
collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
|
||||
if err != nil || collection == nil {
|
||||
@@ -118,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
|
||||
}
|
||||
@@ -143,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() 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 {
|
||||
@@ -169,17 +228,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
}
|
||||
|
||||
hasSuperuserAuth := requestInfo.HasSuperuserAuth()
|
||||
canSkipRuleCheck := hasSuperuserAuth
|
||||
|
||||
// special case for the first superuser creation
|
||||
// ---
|
||||
if !canSkipRuleCheck && collection.Name == core.CollectionNameSuperusers {
|
||||
total, totalErr := e.App.CountRecords(core.CollectionNameSuperusers)
|
||||
canSkipRuleCheck = totalErr == nil && total == 0
|
||||
}
|
||||
// ---
|
||||
|
||||
if !canSkipRuleCheck && collection.CreateRule == nil {
|
||||
if !hasSuperuserAuth && collection.CreateRule == nil {
|
||||
return e.ForbiddenError("Only superusers can perform this action.", nil)
|
||||
}
|
||||
|
||||
@@ -190,6 +239,16 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err))
|
||||
}
|
||||
|
||||
// set a random password for the OAuth2 ignoring its plain password validators
|
||||
var skipPlainPasswordRecordValidators bool
|
||||
if requestInfo.Context == core.RequestInfoContextOAuth2 {
|
||||
if _, ok := data[core.FieldNamePassword]; !ok {
|
||||
data[core.FieldNamePassword] = security.RandomString(30)
|
||||
data[core.FieldNamePassword+"Confirm"] = data[core.FieldNamePassword]
|
||||
skipPlainPasswordRecordValidators = true
|
||||
}
|
||||
}
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestInfo.Body
|
||||
requestInfo.Body = data
|
||||
@@ -200,6 +259,83 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
}
|
||||
form.Load(data)
|
||||
|
||||
if skipPlainPasswordRecordValidators {
|
||||
// unset the plain value to skip the plain password field validators
|
||||
if raw, ok := record.GetRaw(core.FieldNamePassword).(*core.PasswordFieldValue); ok {
|
||||
raw.Plain = ""
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -211,59 +347,9 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
|
||||
// temporary save the record and check it against the create and manage rules
|
||||
if !canSkipRuleCheck && e.Collection.CreateRule != nil {
|
||||
// temporary grant manager access level
|
||||
form.GrantManagerAccess()
|
||||
|
||||
// manually unset the verified field to prevent manage API rule misuse in case the rule relies on it
|
||||
initialVerified := e.Record.Verified()
|
||||
if initialVerified {
|
||||
e.Record.SetVerified(false)
|
||||
}
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *e.Collection.CreateRule == "" {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error {
|
||||
foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
|
||||
// reset the form access level in case it satisfies the Manage API rule
|
||||
if !hasAuthManageAccess(txApp, requestInfo, foundRecord) {
|
||||
form.ResetAccess()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if testErr != nil {
|
||||
return e.BadRequestError("Failed to create record.", testErr)
|
||||
}
|
||||
|
||||
// restore initial verified state (it will be further validated on submit)
|
||||
if initialVerified != e.Record.Verified() {
|
||||
e.Record.SetVerified(initialVerified)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record.", err))
|
||||
return firstApiError(err, e.BadRequestError("Failed to create record", err))
|
||||
}
|
||||
|
||||
err = EnrichRecord(e.RequestEvent, e.Record)
|
||||
@@ -271,14 +357,16 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
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
|
||||
}
|
||||
|
||||
if optFinalizer != nil {
|
||||
isOptFinalizerCalled = true
|
||||
err = optFinalizer()
|
||||
err = optFinalizer(e.Record)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", err))
|
||||
}
|
||||
@@ -292,7 +380,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
|
||||
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
|
||||
if !isOptFinalizerCalled && optFinalizer != nil {
|
||||
if err := optFinalizer(); err != nil {
|
||||
if err := optFinalizer(event.Record); err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", err))
|
||||
}
|
||||
}
|
||||
@@ -301,7 +389,7 @@ func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
func recordUpdate(optFinalizer func() 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 {
|
||||
@@ -351,12 +439,18 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
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
|
||||
}
|
||||
@@ -373,6 +467,14 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
}
|
||||
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)
|
||||
@@ -383,9 +485,6 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error {
|
||||
form.SetApp(e.App)
|
||||
form.SetRecord(e.Record)
|
||||
if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) {
|
||||
form.GrantManagerAccess()
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
if err != nil {
|
||||
@@ -397,14 +496,16 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
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
|
||||
}
|
||||
|
||||
if optFinalizer != nil {
|
||||
isOptFinalizerCalled = true
|
||||
err = optFinalizer()
|
||||
err = optFinalizer(e.Record)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
|
||||
}
|
||||
@@ -418,7 +519,7 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
|
||||
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
|
||||
if !isOptFinalizerCalled && optFinalizer != nil {
|
||||
if err := optFinalizer(); err != nil {
|
||||
if err := optFinalizer(event.Record); err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err)))
|
||||
}
|
||||
}
|
||||
@@ -427,7 +528,7 @@ func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
func recordDelete(optFinalizer func() 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 {
|
||||
@@ -460,12 +561,18 @@ func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
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
|
||||
}
|
||||
@@ -487,14 +594,16 @@ func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
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
|
||||
}
|
||||
|
||||
if optFinalizer != nil {
|
||||
isOptFinalizerCalled = true
|
||||
err = optFinalizer()
|
||||
err = optFinalizer(e.Record)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
|
||||
}
|
||||
@@ -508,7 +617,7 @@ func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error {
|
||||
|
||||
// e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task
|
||||
if !isOptFinalizerCalled && optFinalizer != nil {
|
||||
if err := optFinalizer(); err != nil {
|
||||
if err := optFinalizer(event.Record); err != nil {
|
||||
return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err)))
|
||||
}
|
||||
}
|
||||
@@ -534,9 +643,28 @@ func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[strin
|
||||
return nil, err
|
||||
}
|
||||
if len(uploadedFiles) > 0 {
|
||||
for k, v := range uploadedFiles {
|
||||
result[k] = v
|
||||
for k, files := range uploadedFiles {
|
||||
uploaded := make([]any, 0, len(files))
|
||||
|
||||
// if not remove/prepend/append -> merge with the submitted
|
||||
// info.Body values to prevent accidental old files deletion
|
||||
if info.Body[k] != nil &&
|
||||
!strings.HasPrefix(k, "+") &&
|
||||
!strings.HasSuffix(k, "+") &&
|
||||
!strings.HasSuffix(k, "-") {
|
||||
existing := list.ToUniqueStringSlice(info.Body[k])
|
||||
for _, name := range existing {
|
||||
uploaded = append(uploaded, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
uploaded = append(uploaded, file)
|
||||
}
|
||||
|
||||
result[k] = uploaded
|
||||
}
|
||||
|
||||
result = record.ReplaceModifiers(result)
|
||||
}
|
||||
|
||||
@@ -597,3 +725,42 @@ func extractUploadedFiles(re *core.RequestEvent, collection *core.Collection, pr
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, collection *core.Collection, query *dbx.SelectQuery) bool {
|
||||
if !collection.IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := collection.ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
resolver := core.NewRecordFieldResolver(app, collection, requestInfo, true)
|
||||
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
app.Logger().Error("Manage rule build expression error", "error", err, "collectionId", collection.Id)
|
||||
return false
|
||||
}
|
||||
query.AndWhere(expr)
|
||||
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists int
|
||||
|
||||
err = query.Limit(1).Row(&exists)
|
||||
|
||||
return err == nil && exists > 0
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestRecordCrudAuthOriginCreate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
@@ -284,7 +284,7 @@ func TestRecordCrudAuthOriginUpdate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameAuthOrigins + "/records/9r2j0m74260ur8i",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestRecordCrudExternalAuthCreate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
@@ -286,7 +286,7 @@ func TestRecordCrudExternalAuthUpdate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameExternalAuths + "/records/dlmflokuq1xl342",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
|
||||
@@ -170,7 +170,7 @@ func TestRecordCrudMFADelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
@@ -187,7 +187,7 @@ func TestRecordCrudMFADelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
@@ -204,6 +204,23 @@ func TestRecordCrudMFADelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superusers auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubMFARecords(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -273,7 +290,7 @@ func TestRecordCrudMFACreate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameMFAs + "/records",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
@@ -354,7 +371,7 @@ func TestRecordCrudMFAUpdate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameMFAs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
|
||||
@@ -170,12 +170,12 @@ func TestRecordCrudOTPDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "non-owner",
|
||||
Name: "non-owner auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
@@ -187,12 +187,12 @@ func TestRecordCrudOTPDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "owner",
|
||||
Name: "owner regular auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
@@ -204,6 +204,23 @@ func TestRecordCrudOTPDelete(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superusers auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
if err := tests.StubOTPRecords(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -273,7 +290,7 @@ func TestRecordCrudOTPCreate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameOTPs + "/records",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
@@ -354,7 +371,7 @@ func TestRecordCrudOTPUpdate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameOTPs + "/records/user1_0",
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestRecordCrudSuperuserList(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -93,7 +93,7 @@ func TestRecordCrudSuperuserView(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -142,7 +142,7 @@ func TestRecordCrudSuperuserDelete(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sbmbsdb40jyxf7h",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -162,7 +162,7 @@ func TestRecordCrudSuperuserDelete(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all other superusers
|
||||
@@ -229,48 +229,13 @@ func TestRecordCrudSuperuserCreate(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "guest creating first superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
|
||||
Body: body(),
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
// delete all superusers
|
||||
_, err := app.DB().NewQuery("DELETE FROM {{" + core.CollectionNameSuperusers + "}}").Execute()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedContent: []string{
|
||||
`"collectionName":"_superusers"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
NotExpectedContent: []string{
|
||||
// because the action has no auth the email field shouldn't be returned if emailVisibility is not set
|
||||
`"email"`,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnRecordEnrich": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateSuccess": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateSuccess": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "superusers auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
@@ -339,7 +304,7 @@ func TestRecordCrudSuperuserUpdate(t *testing.T) {
|
||||
URL: "/api/collections/" + core.CollectionNameSuperusers + "/records/sywbhecnh46rhm0",
|
||||
Headers: map[string]string{
|
||||
// _superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Body: body(),
|
||||
ExpectedContent: []string{
|
||||
|
||||
+284
-83
@@ -2,7 +2,6 @@ package apis_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -121,7 +120,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -145,7 +144,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records?filter=text~'test'&sort=-bool",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -167,7 +166,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records?filter=invalid~'test'",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -178,7 +177,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -259,6 +258,110 @@ func TestRecordCrudList(t *testing.T) {
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record that matches the collection list rule with hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records",
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock hidden field
|
||||
col.Fields.GetByName("title").SetHidden(true)
|
||||
|
||||
col.ListRule = types.Pointer("title ~ 'test'")
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":4`,
|
||||
`"items":[{`,
|
||||
`"id":"1tmknxy2868d869"`,
|
||||
`"id":"lcl9d87w22ml6jy"`,
|
||||
`"id":"7nwo8tuiatetxdm"`,
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated regular record filtering with a hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records?filter=title~'test'",
|
||||
Headers: map[string]string{
|
||||
// clients, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.0ONnm_BsvPRZyDNT31GN1CKUB6uQRxvVvQ-Wc9AZfG0",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock hidden field
|
||||
col.Fields.GetByName("title").SetHidden(true)
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser filtering with a hidden field",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo3/records?filter=title~'test'",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock hidden field
|
||||
col.Fields.GetByName("title").SetHidden(true)
|
||||
|
||||
if err = app.Save(col); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":4`,
|
||||
`"items":[{`,
|
||||
`"id":"1tmknxy2868d869"`,
|
||||
`"id":"lcl9d87w22ml6jy"`,
|
||||
`"id":"7nwo8tuiatetxdm"`,
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordsListRequest": 1,
|
||||
"OnRecordEnrich": 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: ":rule modifer",
|
||||
Method: http.MethodGet,
|
||||
@@ -279,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`,
|
||||
@@ -298,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`,
|
||||
@@ -314,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
|
||||
// -----------------------------------------------------------
|
||||
@@ -420,7 +575,7 @@ func TestRecordCrudList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/nologin/records",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -698,7 +853,7 @@ func TestRecordCrudView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records/imy661ixudk5izi",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -738,7 +893,7 @@ func TestRecordCrudView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/demo1/records/al1h9ijdeojtsjy?expand=rel_one,rel_many.rel,missing&perPage=2&sort=created",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -758,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
|
||||
// -----------------------------------------------------------
|
||||
@@ -833,7 +1014,7 @@ func TestRecordCrudView(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/collections/nologin/records/oos036e9xvqeexy",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1090,7 +1271,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -1105,23 +1286,30 @@ 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{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
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",
|
||||
@@ -1197,7 +1385,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo3/records/7nwo8tuiatetxdm",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -1223,7 +1411,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/demo3/records/1tmknxy2868d869",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -1248,7 +1436,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/collections/users/records/oap640cot4yru2s",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
@@ -1459,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)",
|
||||
@@ -1475,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",
|
||||
@@ -1544,7 +1726,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"rel_many_cascade":"lcl9d87w22ml6jy"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1585,7 +1767,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
Body: formData,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": mp.FormDataContentType(),
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1627,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",
|
||||
@@ -1684,29 +1863,44 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
// validate events are not fired because the unique check will fail during dry submit
|
||||
// "OnModelValidate": 1,
|
||||
// "OnRecordValidate": 1,
|
||||
"*": 0,
|
||||
"OnRecordCreateRequest": 1,
|
||||
"OnModelCreate": 1,
|
||||
"OnModelCreateExecute": 1,
|
||||
"OnModelAfterCreateError": 1,
|
||||
"OnModelValidate": 1,
|
||||
"OnRecordCreate": 1,
|
||||
"OnRecordCreateExecute": 1,
|
||||
"OnRecordAfterCreateError": 1,
|
||||
"OnRecordValidate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
@@ -1720,7 +1914,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"title": "test"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1746,7 +1940,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"title": "test"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1772,7 +1966,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"title": "test"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1802,7 +1996,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"title": "test"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -1834,7 +2028,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"passwordConfirm":"1234567890"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1865,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)",
|
||||
@@ -1916,7 +2105,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"username":"Users75657"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -1943,7 +2132,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"rel":"invalid"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -2025,7 +2214,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"verified":true
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -2169,7 +2358,7 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
@@ -2345,7 +2534,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
|
||||
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123}`,
|
||||
router.JSONPayloadKey: `{"title": "title_test3", "testPayload": 123, "files":"300_JdfBOieXAW.png"}`,
|
||||
}, "files")
|
||||
if err3 != nil {
|
||||
t.Fatal(err3)
|
||||
@@ -2578,7 +2767,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"rel_many_cascade":"lcl9d87w22ml6jy"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -2619,7 +2808,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
Body: formData,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": mp.FormDataContentType(),
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -2686,6 +2875,8 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
`"title":"title_test3"`,
|
||||
`"files":["`,
|
||||
`"300_JdfBOieXAW.png"`,
|
||||
`"tmpfile_`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"*": 0,
|
||||
@@ -2702,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",
|
||||
@@ -2726,7 +2927,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"id": "mk5fmymtx4wspra"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -2829,7 +3030,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"verified":false
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -2857,7 +3058,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"rel":"invalid"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -3013,7 +3214,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"verified":false
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -3251,7 +3452,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
||||
col, err := app.FindCollectionByNameOrId("demo3")
|
||||
|
||||
+114
-66
@@ -6,13 +6,16 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"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 (
|
||||
@@ -20,6 +23,8 @@ const (
|
||||
fieldsQueryParam = "fields"
|
||||
)
|
||||
|
||||
var ErrMFA = errors.New("mfa required")
|
||||
|
||||
// RecordAuthResponse writes standardized json record auth response
|
||||
// into the specified request context.
|
||||
//
|
||||
@@ -38,6 +43,13 @@ func RecordAuthResponse(e *core.RequestEvent, authRecord *core.Record, authMetho
|
||||
}
|
||||
|
||||
func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token string, authMethod string, meta any) error {
|
||||
if authRecord.IsSuperuser() {
|
||||
allowedIPs := e.App.Settings().SuperuserIPs
|
||||
if len(allowedIPs) > 0 && !isIPInList(allowedIPs, e.RealIP()) {
|
||||
return e.ForbiddenError("", errors.New("superuser IP is not whitelisted"))
|
||||
}
|
||||
}
|
||||
|
||||
originalRequestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -70,9 +82,12 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
|
||||
// require additional authentication
|
||||
if mfaId != "" {
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{
|
||||
// eagerly write the mfa response and return an err so that
|
||||
// external middlewars are aware that the auth response requires an extra step
|
||||
e.JSON(http.StatusUnauthorized, map[string]string{
|
||||
"mfaId": mfaId,
|
||||
})
|
||||
return ErrMFA
|
||||
}
|
||||
// ---
|
||||
|
||||
@@ -109,20 +124,27 @@ func recordAuthResponse(e *core.RequestEvent, authRecord *core.Record, token str
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"token": e.Token,
|
||||
"record": e.Record,
|
||||
result := struct {
|
||||
Meta any `json:"meta,omitempty"`
|
||||
Record *core.Record `json:"record"`
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: e.Token,
|
||||
Record: e.Record,
|
||||
}
|
||||
|
||||
if e.Meta != nil {
|
||||
result["meta"] = e.Meta
|
||||
result.Meta = e.Meta
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
return execAfterSuccessTx(true, e.App, func() error {
|
||||
return e.JSON(http.StatusOK, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule.
|
||||
// wantsMFA checks whether to enable MFA for the specified auth record based on its MFA rule
|
||||
// (note: returns true even in case of an error as a safer default).
|
||||
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
rule := record.Collection().MFA.Rule
|
||||
if rule == "" {
|
||||
@@ -131,10 +153,10 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
|
||||
requestInfo, err := e.RequestInfo()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return true, err
|
||||
}
|
||||
|
||||
var exists bool
|
||||
var exists int
|
||||
|
||||
query := e.App.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
@@ -144,16 +166,20 @@ func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
|
||||
resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(rule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return true, err
|
||||
}
|
||||
|
||||
err = resolver.UpdateQuery(query)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
|
||||
err = query.AndWhere(expr).Limit(1).Row(&exists)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
return true, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
return exists > 0, nil
|
||||
}
|
||||
|
||||
// checkMFA handles any MFA auth checks that needs to be performed for the specified request event.
|
||||
@@ -166,11 +192,10 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
||||
}
|
||||
|
||||
ok, err := wantsMFA(e, authRecord)
|
||||
if err != nil {
|
||||
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
|
||||
}
|
||||
if !ok {
|
||||
if err != nil {
|
||||
return "", e.BadRequestError("Failed to authenticate.", fmt.Errorf("MFA rule failure: %w", err))
|
||||
}
|
||||
|
||||
return "", nil // no mfa needed for this auth record
|
||||
}
|
||||
|
||||
@@ -214,7 +239,7 @@ func checkMFA(e *core.RequestEvent, authRecord *core.Record, currentAuthMethod s
|
||||
}
|
||||
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
|
||||
deleteMFA()
|
||||
return "", firstApiError(err, e.BadRequestError("Invalid or expired MFA session.", err))
|
||||
return "", e.BadRequestError("Invalid or expired MFA session.", err)
|
||||
}
|
||||
|
||||
if mfa.RecordRef() != authRecord.Id || mfa.CollectionRef() != authRecord.Collection().Id {
|
||||
@@ -256,13 +281,13 @@ func EnrichRecords(e *core.RequestEvent, records []*core.Record, defaultExpands
|
||||
|
||||
return triggerRecordEnrichHooks(e.App, info, records, func() error {
|
||||
expands := defaultExpands
|
||||
if param := e.Request.URL.Query().Get(expandQueryParam); param != "" {
|
||||
if param := info.Query[expandQueryParam]; param != "" {
|
||||
expands = append(expands, strings.Split(param, ",")...)
|
||||
}
|
||||
|
||||
err := defaultEnrichRecords(e.App, info, records, expands...)
|
||||
if err != nil {
|
||||
// only log as it is not critical
|
||||
// only log because it is not critical
|
||||
e.App.Logger().Warn("failed to apply default enriching", "error", err)
|
||||
}
|
||||
|
||||
@@ -270,8 +295,6 @@ func EnrichRecords(e *core.RequestEvent, records []*core.Record, defaultExpands
|
||||
})
|
||||
}
|
||||
|
||||
var iterate func(record *core.Record) error
|
||||
|
||||
type iterator[T any] struct {
|
||||
items []T
|
||||
index int
|
||||
@@ -297,6 +320,7 @@ func triggerRecordEnrichHooks(app core.App, requestInfo *core.RequestInfo, recor
|
||||
event.App = app
|
||||
event.RequestInfo = requestInfo
|
||||
|
||||
var iterate func(record *core.Record) error
|
||||
iterate = func(record *core.Record) error {
|
||||
if record == nil {
|
||||
return nil
|
||||
@@ -350,6 +374,7 @@ func defaultEnrichRecords(app core.App, requestInfo *core.RequestInfo, records [
|
||||
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(app core.App, originalRequestInfo *core.RequestInfo) core.ExpandFetchFunc {
|
||||
// shallow clone the provided request info to set an "expand" context
|
||||
requestInfoClone := *originalRequestInfo
|
||||
requestInfoPtr := &requestInfoClone
|
||||
requestInfoPtr.Context = core.RequestInfoContextExpand
|
||||
@@ -366,12 +391,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
|
||||
@@ -444,18 +475,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
|
||||
}
|
||||
// ---
|
||||
@@ -470,40 +507,6 @@ func autoResolveRecordsFlags(app core.App, records []*core.Record, requestInfo *
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasAuthManageAccess checks whether the client is allowed to have
|
||||
// [forms.RecordUpsert] auth management permissions
|
||||
// (e.g. allowing to change system auth fields without oldPassword).
|
||||
func hasAuthManageAccess(app core.App, requestInfo *core.RequestInfo, record *core.Record) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
}
|
||||
|
||||
manageRule := record.Collection().ManageRule
|
||||
|
||||
if manageRule == nil || *manageRule == "" {
|
||||
return false // only for superusers (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestInfo == nil || requestInfo.Auth == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := core.NewRecordFieldResolver(app, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, findErr := app.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var superuserOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
@@ -558,18 +561,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)
|
||||
@@ -594,8 +622,28 @@ func authAlert(e *core.RequestEvent, authRecord *core.Record) error {
|
||||
}
|
||||
|
||||
// send email alert for the new origin auth (skip first login)
|
||||
//
|
||||
// Note: The "fake" timeout is a temp solution to avoid blocking
|
||||
// for too long when the SMTP server is not accessible, due
|
||||
// to the lack of context cancellation support in the underlying
|
||||
// mailer and net/smtp package.
|
||||
// The goroutine technically "leaks" but we assume that the OS will
|
||||
// terminate the connection after some time (usually after 3-4 mins).
|
||||
if !isFirstLogin && currentOrigin.IsNew() && authRecord.Email() != "" {
|
||||
if err := mails.SendRecordAuthAlert(e.App, authRecord); err != nil {
|
||||
mailSent := make(chan error, 1)
|
||||
|
||||
timer := time.AfterFunc(15*time.Second, func() {
|
||||
mailSent <- errors.New("auth alert mail send wait timeout reached")
|
||||
})
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
err := mails.SendRecordAuthAlert(e.App, authRecord, alertInfo)
|
||||
timer.Stop()
|
||||
mailSent <- err
|
||||
})
|
||||
|
||||
err = <-mailSent
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+59
-13
@@ -2,6 +2,7 @@ package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -23,6 +24,14 @@ func TestEnrichRecords(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
freshRecords := func(records []*core.Record) []*core.Record {
|
||||
result := make([]*core.Record, len(records))
|
||||
for i, r := range records {
|
||||
result[i] = r.Fresh()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
user, err := app.FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -77,7 +86,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility] guest",
|
||||
auth: nil,
|
||||
records: usersRecords,
|
||||
records: freshRecords(usersRecords),
|
||||
queryExpand: "",
|
||||
defaultExpands: nil,
|
||||
expected: []string{
|
||||
@@ -91,7 +100,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility] owner",
|
||||
auth: user,
|
||||
records: usersRecords,
|
||||
records: freshRecords(usersRecords),
|
||||
queryExpand: "",
|
||||
defaultExpands: nil,
|
||||
expected: []string{
|
||||
@@ -103,7 +112,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility] manager",
|
||||
auth: user,
|
||||
records: nologinRecords,
|
||||
records: freshRecords(nologinRecords),
|
||||
queryExpand: "",
|
||||
defaultExpands: nil,
|
||||
expected: []string{
|
||||
@@ -115,7 +124,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility] superuser",
|
||||
auth: superuser,
|
||||
records: nologinRecords,
|
||||
records: freshRecords(nologinRecords),
|
||||
queryExpand: "",
|
||||
defaultExpands: nil,
|
||||
expected: []string{
|
||||
@@ -127,7 +136,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility + expand] recursive auth rule checks (regular user)",
|
||||
auth: user,
|
||||
records: demo1Records,
|
||||
records: freshRecords(demo1Records),
|
||||
queryExpand: "",
|
||||
defaultExpands: []string{"rel_many"},
|
||||
expected: []string{
|
||||
@@ -144,7 +153,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[emailVisibility + expand] recursive auth rule checks (superuser)",
|
||||
auth: superuser,
|
||||
records: demo1Records,
|
||||
records: freshRecords(demo1Records),
|
||||
queryExpand: "",
|
||||
defaultExpands: []string{"rel_many"},
|
||||
expected: []string{
|
||||
@@ -152,6 +161,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
`"test@example.com"`,
|
||||
`"expand":{"rel_many"`,
|
||||
`"id":"bgs820n361vj1qd"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"id":"oap640cot4yru2s"`,
|
||||
},
|
||||
notExpected: []string{
|
||||
@@ -163,7 +173,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[expand] guest (query)",
|
||||
auth: nil,
|
||||
records: usersRecords,
|
||||
records: freshRecords(usersRecords),
|
||||
queryExpand: "rel",
|
||||
defaultExpands: nil,
|
||||
expected: []string{
|
||||
@@ -179,7 +189,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[expand] guest (default expands)",
|
||||
auth: nil,
|
||||
records: usersRecords,
|
||||
records: freshRecords(usersRecords),
|
||||
queryExpand: "",
|
||||
defaultExpands: []string{"rel"},
|
||||
expected: []string{
|
||||
@@ -192,7 +202,7 @@ func TestEnrichRecords(t *testing.T) {
|
||||
{
|
||||
name: "[expand] @request.context=expand check",
|
||||
auth: nil,
|
||||
records: demo5Records,
|
||||
records: freshRecords(demo5Records),
|
||||
queryExpand: "rel_one",
|
||||
defaultExpands: []string{"rel_many"},
|
||||
expected: []string{
|
||||
@@ -575,8 +585,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
||||
user.Collection().MFA.Rule = "1=1"
|
||||
|
||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil, got error: %v", err)
|
||||
if !errors.Is(err, apis.ErrMFA) {
|
||||
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
@@ -593,8 +603,8 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
||||
resetMFAs(user)
|
||||
|
||||
err = apis.RecordAuthResponse(event, user, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil, got error: %v", err)
|
||||
if !errors.Is(err, apis.ErrMFA) {
|
||||
t.Fatalf("Expected ErrMFA, got: %v", err)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
@@ -749,3 +759,39 @@ func TestRecordAuthResponseMFACheck(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordAuthResponseSuperuserIPsWhitelistCheck(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Settings().TrustedProxy.Headers = []string{"x-test-ip"}
|
||||
|
||||
event := new(core.RequestEvent)
|
||||
event.App = app
|
||||
event.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
event.Request.Header.Set("x-test-ip", "127.0.0.1")
|
||||
event.Response = httptest.NewRecorder()
|
||||
|
||||
t.Run("non-whitelisted", func(t *testing.T) {
|
||||
app.Settings().SuperuserIPs = []string{"0.0.0.0"}
|
||||
|
||||
err = apis.RecordAuthResponse(event, superuser, "example", nil)
|
||||
if err == nil {
|
||||
t.Fatal("Expected response error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitelisted", func(t *testing.T) {
|
||||
app.Settings().SuperuserIPs = []string{"0.0.0.0", "127.0.0.1"}
|
||||
|
||||
err = apis.RecordAuthResponse(event, superuser, "example", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+86
-48
@@ -16,22 +16,19 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"golang.org/x/crypto/acme"
|
||||
"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-ancestors 'none'"
|
||||
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
ShowStartBanner bool
|
||||
|
||||
// DashboardPath specifies the route path to the superusers dashboard interface
|
||||
// (default to "/_/{path...}").
|
||||
//
|
||||
// Note: Must include the "{path...}" wildcard parameter.
|
||||
DashboardPath string
|
||||
|
||||
// HttpAddr is the TCP address to listen for the HTTP server (eg. "127.0.0.1:80").
|
||||
HttpAddr string
|
||||
|
||||
@@ -66,12 +63,6 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
if config.DashboardPath == "" {
|
||||
config.DashboardPath = "/_/{path...}"
|
||||
} else if !strings.HasSuffix(config.DashboardPath, "{path...}") {
|
||||
return errors.New("invalid dashboard path - missing {path...} wildcard")
|
||||
}
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
err := app.RunAllMigrations()
|
||||
if err != nil {
|
||||
@@ -83,17 +74,30 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pbRouter.Bind(CORSWithConfig(CORSConfig{
|
||||
pbRouter.Bind(CORS(CORSConfig{
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
pbRouter.BindFunc(installerRedirect(app, config.DashboardPath))
|
||||
// @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")
|
||||
}
|
||||
|
||||
pbRouter.GET(config.DashboardPath, Static(ui.DistDirFS, false)).
|
||||
BindFunc(dashboardRemoveInstallerParam()).
|
||||
BindFunc(dashboardCacheControl()).
|
||||
Bind(Gzip())
|
||||
if e.Response.Header().Get("Content-Security-Policy") == "" {
|
||||
e.Response.Header().Set("Content-Security-Policy", defaultCSP)
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}).
|
||||
Bind(Gzip())
|
||||
}
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
@@ -145,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
|
||||
@@ -155,12 +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
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
// graceful shutdown
|
||||
@@ -206,6 +204,15 @@ 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()
|
||||
@@ -215,10 +222,20 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
|
||||
e.Server.Handler = handler
|
||||
|
||||
addr := e.Server.Addr
|
||||
if config.HttpsAddr == "" {
|
||||
baseURL = "http://" + serverAddrToHost(serveEvent.Server.Addr)
|
||||
} else {
|
||||
baseURL = "https://"
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
baseURL += config.CertificateDomains[0]
|
||||
} else {
|
||||
baseURL += serverAddrToHost(serveEvent.Server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
addr := e.Server.Addr
|
||||
if addr == "" {
|
||||
// fallback similar to the std Server.ListenAndServe/ListenAndServeTLS
|
||||
if config.HttpsAddr != "" {
|
||||
addr = ":https"
|
||||
} else {
|
||||
@@ -226,28 +243,37 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
var lnErr error
|
||||
if e.Listener == nil {
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
listener = e.Listener
|
||||
}
|
||||
|
||||
listener, lnErr = net.Listen("tcp", addr)
|
||||
if e.InstallerFunc != nil {
|
||||
app := e.App
|
||||
installerFunc := e.InstallerFunc
|
||||
routine.FireAndForget(func() {
|
||||
if err := loadInstaller(app, baseURL, installerFunc); err != nil {
|
||||
app.Logger().Warn("Failed to initialize installer", "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return lnErr
|
||||
return nil
|
||||
})
|
||||
if serveHookErr != nil {
|
||||
return serveHookErr
|
||||
}
|
||||
|
||||
if listener == nil {
|
||||
//nolint:staticcheck
|
||||
return errors.New("The OnServe listener was not initialized. Did you forget to call the ServeEvent.Next() method?")
|
||||
}
|
||||
|
||||
if config.ShowStartBanner {
|
||||
schema := "http"
|
||||
addr := server.Addr
|
||||
|
||||
if config.HttpsAddr != "" {
|
||||
schema = "https"
|
||||
|
||||
if len(config.CertificateDomains) > 0 {
|
||||
addr = config.CertificateDomains[0]
|
||||
}
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
@@ -255,12 +281,16 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, addr),
|
||||
color.CyanString("%s", baseURL),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf("├─ REST API: %s\n", color.CyanString("%s://%s/api/", schema, addr))
|
||||
regular.Printf("└─ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, addr))
|
||||
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
|
||||
@@ -271,10 +301,10 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
}
|
||||
|
||||
// start HTTPS server
|
||||
serveErr = server.ServeTLS(listener, "", "")
|
||||
serveErr = serveEvent.Server.ServeTLS(listener, "", "")
|
||||
} else {
|
||||
// OR start HTTP server
|
||||
serveErr = server.Serve(listener)
|
||||
serveErr = serveEvent.Server.Serve(listener)
|
||||
}
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
return serveErr
|
||||
@@ -283,6 +313,14 @@ func Serve(app core.App, config ServeConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverAddrToHost loosely converts http.Server.Addr string into a host to print.
|
||||
func serverAddrToHost(addr string) string {
|
||||
if addr == "" || strings.HasSuffix(addr, ":http") || strings.HasSuffix(addr, ":https") {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
type serverErrorLogWriter struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
+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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+93
-19
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ func TestSettingsList(t *testing.T) {
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/settings",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -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{
|
||||
@@ -102,7 +130,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
URL: "/api/settings",
|
||||
Body: strings.NewReader(``),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
@@ -129,7 +157,7 @@ func TestSettingsSet(t *testing.T) {
|
||||
URL: "/api/settings",
|
||||
Body: strings.NewReader(`{"meta":{"appName":""}}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -150,7 +178,26 @@ func TestSettingsSet(t *testing.T) {
|
||||
URL: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"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{
|
||||
@@ -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 {
|
||||
@@ -211,7 +285,7 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/settings/test/s3",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -226,7 +300,7 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
URL: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"invalid"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -241,7 +315,7 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
URL: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"storage"}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -293,7 +367,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
URL: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -305,7 +379,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
URL: "/api/settings/test/email",
|
||||
Body: strings.NewReader(`{}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -323,7 +397,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
@@ -359,7 +433,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
@@ -395,7 +469,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
@@ -431,7 +505,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
"email": "test@example.com"
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend() != 1 {
|
||||
@@ -511,7 +585,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
URL: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
@@ -523,7 +597,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
URL: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -547,7 +621,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
"duration": -1
|
||||
}`),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
@@ -571,7 +645,7 @@ func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
"duration": 1
|
||||
}`, privatePem)),
|
||||
Headers: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiY18zMzIzODY2MzM5IiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.v_bMAygr6hXPwD2DpPrFpNQ7dd68Q3pGstmYAsvNBJg",
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
const (
|
||||
runSQLMaxRows = 1000
|
||||
runSQLMaxTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
// bindSQLApi registers the SQL api endpoints.
|
||||
func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
|
||||
subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
|
||||
subGroup.POST("", runSQL)
|
||||
}
|
||||
|
||||
func runSQL(e *core.RequestEvent) error {
|
||||
// extra precaution in case manually invoked from somewhere else
|
||||
if !e.HasSuperuserAuth() {
|
||||
return e.ForbiddenError("", nil)
|
||||
}
|
||||
|
||||
form := runSQLForm{}
|
||||
|
||||
err := e.BindBody(&form)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
|
||||
}
|
||||
|
||||
err = form.validate()
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
|
||||
}
|
||||
|
||||
result, err := executeQuery(e.App, form.Query, runSQLMaxRows)
|
||||
if err != nil {
|
||||
return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
type runSQLForm struct {
|
||||
Query string `form:"query" json:"query"`
|
||||
}
|
||||
|
||||
func (form *runSQLForm) validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Query, validation.Required, validation.Length(0, 5000)),
|
||||
)
|
||||
}
|
||||
|
||||
type runSQLResultColumn struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
}
|
||||
|
||||
type runSQLResult struct {
|
||||
ExecTime int64 `json:"execTime"`
|
||||
AffectedRows int64 `json:"affectedRows"`
|
||||
Columns []runSQLResultColumn `json:"columns"`
|
||||
Rows [][]any `json:"rows"`
|
||||
}
|
||||
|
||||
var knownWriteQueryPrefixes = []string{
|
||||
"INSERT", "CREATE", "UPDATE", "DELETE",
|
||||
"DROP", "DETACH", "ALTER", "REPLACE",
|
||||
}
|
||||
|
||||
func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
// see https://github.com/mattn/go-sqlite3/issues/950
|
||||
return nil, errors.New("empty query")
|
||||
}
|
||||
|
||||
var isPossibleWriteQuery bool
|
||||
|
||||
// loosely check the query type
|
||||
ucQuery := strings.ToUpper(query)
|
||||
if !strings.HasPrefix(ucQuery, "SELECT") {
|
||||
for _, prefix := range knownWriteQueryPrefixes {
|
||||
if strings.HasPrefix(ucQuery, prefix) {
|
||||
isPossibleWriteQuery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// note: don't extend the request context to minimize the risk of
|
||||
// causing integrity issues with custom non-transaction mutations
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
|
||||
defer cancelFunc()
|
||||
|
||||
result := &runSQLResult{
|
||||
// init empty slices to ensure "[]" serialization
|
||||
Columns: []runSQLResultColumn{},
|
||||
Rows: [][]any{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
defer func() {
|
||||
result.ExecTime = time.Since(now).Milliseconds()
|
||||
}()
|
||||
|
||||
// assume write/mutation query
|
||||
// ---------------------------------------------------------------
|
||||
if isPossibleWriteQuery {
|
||||
// auto wrap in transaction in case there are multiple inline queries
|
||||
txErr := app.RunInTransaction(func(txApp core.App) error {
|
||||
execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.AffectedRows, err = execResult.RowsAffected()
|
||||
if err != nil {
|
||||
// non-critical error (e.g. not supported by the driver)
|
||||
txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
return nil, txErr
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// assume query returning rows
|
||||
// ---------------------------------------------------------------
|
||||
rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// populate columns info
|
||||
// ---
|
||||
colTypes, err := rows.ColumnTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, colType := range colTypes {
|
||||
col := runSQLResultColumn{
|
||||
Name: colType.Name(),
|
||||
Type: colType.DatabaseTypeName(),
|
||||
}
|
||||
col.Nullable, _ = colType.Nullable()
|
||||
|
||||
result.Columns = append(result.Columns, col)
|
||||
}
|
||||
|
||||
// populate rows
|
||||
// ---
|
||||
for rows.Next() {
|
||||
if len(result.Rows) >= maxRows {
|
||||
break
|
||||
}
|
||||
|
||||
rowData := make([]any, len(colTypes))
|
||||
for i := 0; i < len(colTypes); i++ {
|
||||
var v *string
|
||||
rowData[i] = &v
|
||||
}
|
||||
|
||||
err := rows.Scan(rowData...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Rows = append(result.Rows, rowData)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestSQLRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "regular user",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
Headers: map[string]string{
|
||||
// users, test2@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "superuser",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[{"name":"1","type":"","nullable":true}]`,
|
||||
`"rows":[["1"]]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "empty query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":""}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"query":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "invalid query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"invalid"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
`SQL logic error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "query with length above the limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 5001) + `"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"query":{`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "query with length equal to the limit",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select '` + strings.Repeat("a", 4985) + `' as id"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[{"name":"id","type":"","nullable":true}]`,
|
||||
`"rows":[["aaa`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "single write query",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key)"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if !app.HasTable("test_sql_table") {
|
||||
t.Fatalf("Missing expected new %q table", "test_sql_table")
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
`"columns":[]`,
|
||||
`"rows":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple write queries",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1)"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
var total int
|
||||
err := app.DB().NewQuery("select count(*) from test_sql_table").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total != 1 {
|
||||
t.Fatalf("Expected exactly 1 row, found: %d", total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":1`,
|
||||
`"columns":[]`,
|
||||
`"rows":[]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple write queries (transaction rollback)",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1);invalid"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
|
||||
if app.HasTable("test_sql_table") {
|
||||
t.Fatalf("Expected table %q to not be created", "test_sql_table")
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
`Raw error:`,
|
||||
`SQL logic error`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
{
|
||||
Name: "multiple read queries",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/sql",
|
||||
Body: strings.NewReader(`{"query":"select 1;select 2"}`),
|
||||
Headers: map[string]string{
|
||||
// superusers, test@example.com
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"execTime":`,
|
||||
`"affectedRows":0`,
|
||||
// only the result of the last query should be returned
|
||||
`"columns":[{"name":"2","type":"","nullable":true}]`,
|
||||
`"rows":[["2"]]`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"*": 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
var allowedOrigins []string
|
||||
var httpAddr string
|
||||
var httpsAddr string
|
||||
var dashboardPath string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "serve [domain(s)]",
|
||||
@@ -40,7 +39,6 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
DashboardPath: dashboardPath,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
CertificateDomains: args,
|
||||
@@ -75,12 +73,5 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
|
||||
)
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&dashboardPath,
|
||||
"dashboard",
|
||||
"/_/{path...}",
|
||||
"The route path to the superusers dashboard; must include the '{path...}' wildcard parameter",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
+102
-21
@@ -7,21 +7,24 @@ import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewSuperuserCommand creates and returns new command for managing
|
||||
// superuser accounts (create, update, delete).
|
||||
// superuser accounts (create, update, upsert, delete).
|
||||
func NewSuperuserCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "superuser",
|
||||
Short: "Manages superuser accounts",
|
||||
Short: "Manage superusers",
|
||||
}
|
||||
|
||||
command.AddCommand(superuserUpsertCommand(app))
|
||||
command.AddCommand(superuserCreateCommand(app))
|
||||
command.AddCommand(superuserUpdateCommand(app))
|
||||
command.AddCommand(superuserDeleteCommand(app))
|
||||
command.AddCommand(superuserOTPCommand(app))
|
||||
command.AddCommand(superuserIPsCommand(app))
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -30,20 +33,20 @@ func superuserUpsertCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upsert",
|
||||
Example: "superuser upsert test@example.com 1234567890",
|
||||
Short: "Creates, or updates if email exists, a single superuser account",
|
||||
Short: "Creates, or updates if email exists, a single superuser",
|
||||
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])
|
||||
@@ -55,7 +58,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())
|
||||
@@ -70,20 +73,20 @@ func superuserCreateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "create",
|
||||
Example: "superuser create test@example.com 1234567890",
|
||||
Short: "Creates a new superuser account",
|
||||
Short: "Creates a new superuser",
|
||||
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)
|
||||
@@ -91,7 +94,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())
|
||||
@@ -106,26 +109,26 @@ func superuserUpdateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "update",
|
||||
Example: "superuser update test@example.com 1234567890",
|
||||
Short: "Changes the password of a single superuser account",
|
||||
Short: "Changes the password of a single superuser",
|
||||
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())
|
||||
@@ -140,21 +143,21 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "delete",
|
||||
Example: "superuser delete test@example.com",
|
||||
Short: "Deletes an existing superuser account",
|
||||
Short: "Deletes an existing superuser",
|
||||
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())
|
||||
@@ -164,3 +167,81 @@ func superuserDeleteCommand(app core.App) *cobra.Command {
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func superuserOTPCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "otp",
|
||||
Example: "superuser otp test@example.com",
|
||||
Short: "Creates a new OTP for the specified superuser",
|
||||
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")
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("superuser with email %q doesn't exist", args[0])
|
||||
}
|
||||
|
||||
if !superuser.Collection().OTP.Enabled {
|
||||
return errors.New("OTP auth is not enabled for the _superusers collection")
|
||||
}
|
||||
|
||||
pass := security.RandomStringWithAlphabet(superuser.Collection().OTP.Length, "1234567890")
|
||||
|
||||
otp := core.NewOTP(app)
|
||||
otp.SetCollectionRef(superuser.Collection().Id)
|
||||
otp.SetRecordRef(superuser.Id)
|
||||
otp.SetPassword(pass)
|
||||
|
||||
err = app.Save(otp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OTP: %w", err)
|
||||
}
|
||||
|
||||
color.New(color.BgGreen, color.FgBlack).Printf("Successfully created OTP for superuser %q:", superuser.Email())
|
||||
color.Green("\n├─ Id: %s", otp.Id)
|
||||
color.Green("├─ Pass: %s", pass)
|
||||
color.Green("└─ Valid: %ds\n\n", superuser.Collection().OTP.Duration)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func superuserIPsCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "ips",
|
||||
Example: "superuser ips 127.0.0.1 10.0.0.0/24",
|
||||
Short: "Updates the superuser IPs whitelist setting (the IPs/subnets arguments must be space separated; leave empty to clear the whitelist restriction)",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
settings := app.Settings()
|
||||
settings.SuperuserIPs = args
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
color.Green("Successfully cleared SuperuserIPs setting!")
|
||||
} else {
|
||||
color.New(color.BgGreen, color.FgBlack).Println("Successfully updated SuperuserIPs setting:")
|
||||
superuserIPs := app.Settings().SuperuserIPs
|
||||
for i, ip := range superuserIPs {
|
||||
if i == len(superuserIPs)-1 {
|
||||
color.Green("└─ %s", ip)
|
||||
} else {
|
||||
color.Green("├─ %s", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/cmd"
|
||||
@@ -308,3 +309,156 @@ func TestSuperuserDeleteCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperuserOTPCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// remove all existing otps
|
||||
otps, err := app.FindAllOTPsByCollection(superusersCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, otp := range otps {
|
||||
err = app.Delete(otp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
enabled bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting superuser",
|
||||
"test_missing@example.com",
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing superuser",
|
||||
"test@example.com",
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing superuser with disabled OTP",
|
||||
"test@example.com",
|
||||
false,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
command := cmd.NewSuperuserCommand(app)
|
||||
command.SetArgs([]string{"otp", s.email})
|
||||
|
||||
superusersCollection.OTP.Enabled = s.enabled
|
||||
if err = app.SaveNoValidate(superusersCollection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
superuser, err := app.FindAuthRecordByEmail(superusersCollection, s.email)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
otps, _ := app.FindAllOTPsByRecord(superuser)
|
||||
if total := len(otps); total != 1 {
|
||||
t.Fatalf("Expected 1 OTP, got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperuserIPsCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
ips []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"no ips",
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid ips",
|
||||
[]string{"127.0.0.1", "invalid"},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid ips",
|
||||
[]string{"127.0.0.1", "::1", "127.0.0.1/24"},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
args := []string{"ips"}
|
||||
args = append(args, s.ips...)
|
||||
|
||||
command := cmd.NewSuperuserCommand(app)
|
||||
command.SetArgs(args)
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
settingIPs := app.Settings().SuperuserIPs
|
||||
|
||||
if len(settingIPs) != len(s.ips) {
|
||||
t.Fatalf("Expected %d ips, got %d (%v)", len(s.ips), len(settingIPs), settingIPs)
|
||||
}
|
||||
|
||||
for _, ip := range settingIPs {
|
||||
if !slices.Contains(s.ips, ip) {
|
||||
t.Fatalf("Missing expected ip %q (%v)", ip, settingIPs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+189
-102
@@ -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.).
|
||||
//
|
||||
@@ -71,7 +77,7 @@ type App interface {
|
||||
Settings() *Settings
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
Store() *store.Store[string, any]
|
||||
|
||||
// Cron returns the app cron instance.
|
||||
Cron() *cron.Cron
|
||||
@@ -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
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -369,16 +421,9 @@ type App interface {
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections]
|
||||
FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// FindCollectionReferences returns information for all relation
|
||||
// fields referencing the provided collection.
|
||||
//
|
||||
@@ -387,6 +432,32 @@ type App interface {
|
||||
// as the excludeIds argument.
|
||||
FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// FindCachedCollectionReferences is similar to [App.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections].
|
||||
FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error)
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
IsCollectionNameUnique(name string, excludeIds ...string) bool
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
@@ -431,6 +502,11 @@ type App interface {
|
||||
// ExternalAuth model that satisfies the non-nil expression.
|
||||
FindFirstExternalAuthByExpr(expr dbx.Expression) (*ExternalAuth, error)
|
||||
|
||||
// DeleteAllExternalAuthsByRecord deletes all ExternalAuth models associated with the provided record.
|
||||
//
|
||||
// Returns a combined error with the failed deletes.
|
||||
DeleteAllExternalAuthsByRecord(authRecord *Record) error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// FindAllMFAsByRecord returns all MFA models linked to the provided auth record.
|
||||
@@ -596,7 +672,7 @@ type App interface {
|
||||
// CanAccessRecord checks if a record is allowed to be accessed by the
|
||||
// specified requestInfo and accessRule.
|
||||
//
|
||||
// Rule and db checks are ignored in case requestInfo.AuthRecord is a superuser.
|
||||
// Rule and db checks are ignored in case requestInfo.Auth is a superuser.
|
||||
//
|
||||
// The returned error indicate that something unexpected happened during
|
||||
// the check (eg. invalid rule or db query error).
|
||||
@@ -633,17 +709,19 @@ type App interface {
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBootstrap hook is triggered on initializing the main application
|
||||
// OnBootstrap hook is triggered when initializing the main application
|
||||
// resources (db, app settings, etc).
|
||||
OnBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnServe hook is triggered on when the app web server is started
|
||||
// (after starting the tcp listener but before initializing the blocking serve task),
|
||||
// OnServe hook is triggered when the app web server is started
|
||||
// (after starting the TCP listener but before initializing the blocking serve task),
|
||||
// allowing you to adjust its options and attach new routes or middlewares.
|
||||
OnServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnTerminate hook is triggered when the app is in the process
|
||||
// of being terminated (ex. on SIGTERM signal).
|
||||
//
|
||||
// Note that the app could be terminated abruptly without awaiting the hook completion.
|
||||
OnTerminate() *hook.Hook[*TerminateEvent]
|
||||
|
||||
// OnBackupCreate hook is triggered on each [App.CreateBackup] call.
|
||||
@@ -661,6 +739,9 @@ type App interface {
|
||||
// OnModelValidate is triggered every time when a model is being validated
|
||||
// (e.g. triggered by App.Validate() or App.Save()).
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
|
||||
//
|
||||
// If the optional "tags" list (Collection id/name, Model table name, etc.) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
@@ -680,7 +761,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is persisted in the database since its wrapping transaction may
|
||||
// not have been committed yet.
|
||||
// If you wan to listen to only the actual persisted events, you can
|
||||
// If you want to listen to only the actual persisted events, you can
|
||||
// bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -703,7 +784,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is persisted in the database since its wrapping transaction may have been
|
||||
// committed yet.
|
||||
// If you wan to listen to only the actual persisted events,
|
||||
// If you want to listen to only the actual persisted events,
|
||||
// you can bind to [OnModelAfterCreateSuccess] or [OnModelAfterCreateError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -718,7 +799,7 @@ type App interface {
|
||||
// Model DB create persistence.
|
||||
//
|
||||
// Note that when a Model is persisted as part of a transaction,
|
||||
// this hook is triggered AFTER the transaction has been committed.
|
||||
// this hook is delayed and executed only AFTER the transaction has been committed.
|
||||
// This hook is NOT triggered in case the transaction rollbacks
|
||||
// (aka. when the model wasn't persisted).
|
||||
//
|
||||
@@ -732,10 +813,11 @@ type App interface {
|
||||
|
||||
// OnModelAfterCreateError is triggered after each failed
|
||||
// Model DB create persistence.
|
||||
// Note that when a Model is persisted as part of a transaction,
|
||||
// this hook is triggered in one of the following cases:
|
||||
// - immediately after App.Save() failure
|
||||
// - on transaction rollback
|
||||
//
|
||||
// Note that the execution of this hook is either immediate or delayed
|
||||
// depending on the error:
|
||||
// - "immediate" on App.Save() failure
|
||||
// - "delayed" on transaction rollback
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
|
||||
@@ -759,7 +841,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is persisted in the database since its wrapping transaction may
|
||||
// not have been committed yet.
|
||||
// If you wan to listen to only the actual persisted events, you can
|
||||
// If you want to listen to only the actual persisted events, you can
|
||||
// bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -782,7 +864,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is persisted in the database since its wrapping transaction may have been
|
||||
// committed yet.
|
||||
// If you wan to listen to only the actual persisted events,
|
||||
// If you want to listen to only the actual persisted events,
|
||||
// you can bind to [OnModelAfterUpdateSuccess] or [OnModelAfterUpdateError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -797,7 +879,7 @@ type App interface {
|
||||
// Model DB update persistence.
|
||||
//
|
||||
// Note that when a Model is persisted as part of a transaction,
|
||||
// this hook is triggered AFTER the transaction has been committed.
|
||||
// this hook is delayed and executed only AFTER the transaction has been committed.
|
||||
// This hook is NOT triggered in case the transaction rollbacks
|
||||
// (aka. when the model changes weren't persisted).
|
||||
//
|
||||
@@ -812,10 +894,10 @@ type App interface {
|
||||
// OnModelAfterUpdateError is triggered after each failed
|
||||
// Model DB update persistence.
|
||||
//
|
||||
// Note that when a Model is persisted as part of a transaction,
|
||||
// this hook is triggered in one of the following cases:
|
||||
// - immediately after App.Save() failure
|
||||
// - on transaction rollback
|
||||
// Note that the execution of this hook is either immediate or delayed
|
||||
// depending on the error:
|
||||
// - "immediate" on App.Save() failure
|
||||
// - "delayed" on transaction rollback
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
|
||||
@@ -833,7 +915,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is deleted from the database since its wrapping transaction may
|
||||
// not have been committed yet.
|
||||
// If you wan to listen to only the actual persisted deleted events, you can
|
||||
// If you want to listen to only the actual persisted deleted events, you can
|
||||
// bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -856,7 +938,7 @@ type App interface {
|
||||
// Note that successful execution doesn't guarantee that the model
|
||||
// is deleted from the database since its wrapping transaction may
|
||||
// not have been committed yet.
|
||||
// If you wan to listen to only the actual persisted deleted events, you can
|
||||
// If you want to listen to only the actual persisted deleted events, you can
|
||||
// bind to [OnModelAfterDeleteSuccess] or [OnModelAfterDeleteError] hooks.
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
@@ -871,7 +953,7 @@ type App interface {
|
||||
// Model DB delete persistence.
|
||||
//
|
||||
// Note that when a Model is deleted as part of a transaction,
|
||||
// this hook is triggered AFTER the transaction has been committed.
|
||||
// this hook is delayed and executed only AFTER the transaction has been committed.
|
||||
// This hook is NOT triggered in case the transaction rollbacks
|
||||
// (aka. when the model delete wasn't persisted).
|
||||
//
|
||||
@@ -886,10 +968,10 @@ type App interface {
|
||||
// OnModelAfterDeleteError is triggered after each failed
|
||||
// Model DB delete persistence.
|
||||
//
|
||||
// Note that when a Model is deleted as part of a transaction,
|
||||
// this hook is triggered in one of the following cases:
|
||||
// - immediately after App.Delete() failure
|
||||
// - on transaction rollback
|
||||
// Note that the execution of this hook is either immediate or delayed
|
||||
// depending on the error:
|
||||
// - "immediate" on App.Delete() failure
|
||||
// - "delayed" on transaction rollback
|
||||
//
|
||||
// For convenience, if you want to listen to only the Record models
|
||||
// events without doing manual type assertion, you can attach to the OnRecord* proxy hooks.
|
||||
@@ -904,10 +986,9 @@ type App interface {
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordEnrich is triggered every time when a record is enriched
|
||||
// (during realtime message seriazation, as part of the builtin Record
|
||||
// responses, or when [apis.EnrichRecord] is invoked).
|
||||
// (as part of the builtin Record responses, during realtime message seriazation, or when [apis.EnrichRecord] is invoked).
|
||||
//
|
||||
// It could be used for example to redact/hide or add computed temp
|
||||
// It could be used for example to redact/hide or add computed temporary
|
||||
// Record model props only for the specific request info. For example:
|
||||
//
|
||||
// app.OnRecordEnrich("posts").BindFunc(func(e core.*RecordEnrichEvent) {
|
||||
@@ -928,7 +1009,7 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordEnrich(tags ...string) *hook.TaggedHook[*RecordEnrichEvent]
|
||||
|
||||
// OnRecordValidate is a proxy Record model hook for [OnModelValidate].
|
||||
// OnRecordValidate is a Record proxy model hook of [OnModelValidate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -937,28 +1018,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordCreate is a proxy Record model hook for [OnModelCreate].
|
||||
// OnRecordCreate is a Record proxy model hook of [OnModelCreate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordCreate(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordCreateExecute is a proxy Record model hook for [OnModelCreateExecute].
|
||||
// OnRecordCreateExecute is a Record proxy model hook of [OnModelCreateExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordCreateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterCreateSuccess is a proxy Record model hook for [OnModelAfterCreateSuccess].
|
||||
// OnRecordAfterCreateSuccess is a Record proxy model hook of [OnModelAfterCreateSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterCreateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterCreateError is a proxy Record model hook for [OnModelAfterCreateError].
|
||||
// OnRecordAfterCreateError is a Record proxy model hook of [OnModelAfterCreateError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -967,28 +1048,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordUpdate is a proxy Record model hook for [OnModelUpdate].
|
||||
// OnRecordUpdate is a Record proxy model hook of [OnModelUpdate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordUpdate(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordUpdateExecute is a proxy Record model hook for [OnModelUpdateExecute].
|
||||
// OnRecordUpdateExecute is a Record proxy model hook of [OnModelUpdateExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordUpdateExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterUpdateSuccess is a proxy Record model hook for [OnModelAfterUpdateSuccess].
|
||||
// OnRecordAfterUpdateSuccess is a Record proxy model hook of [OnModelAfterUpdateSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterUpdateError is a proxy Record model hook for [OnModelAfterUpdateError].
|
||||
// OnRecordAfterUpdateError is a Record proxy model hook of [OnModelAfterUpdateError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -997,28 +1078,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnRecordDelete is a proxy Record model hook for [OnModelDelete].
|
||||
// OnRecordDelete is a Record proxy model hook of [OnModelDelete].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordDelete(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordDeleteExecute is a proxy Record model hook for [OnModelDeleteExecute].
|
||||
// OnRecordDeleteExecute is a Record proxy model hook of [OnModelDeleteExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordDeleteExecute(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterDeleteSuccess is a proxy Record model hook for [OnModelAfterDeleteSuccess].
|
||||
// OnRecordAfterDeleteSuccess is a Record proxy model hook of [OnModelAfterDeleteSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*RecordEvent]
|
||||
|
||||
// OnRecordAfterDeleteError is a proxy Record model hook for [OnModelAfterDeleteError].
|
||||
// OnRecordAfterDeleteError is a Record proxy model hook of [OnModelAfterDeleteError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1029,7 +1110,7 @@ type App interface {
|
||||
// Collection models event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnCollectionValidate is a proxy Collection model hook for [OnModelValidate].
|
||||
// OnCollectionValidate is a Collection proxy model hook of [OnModelValidate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1038,28 +1119,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnCollectionCreate is a proxy Collection model hook for [OnModelCreate].
|
||||
// OnCollectionCreate is a Collection proxy model hook of [OnModelCreate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionCreate(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionCreateExecute is a proxy Collection model hook for [OnModelCreateExecute].
|
||||
// OnCollectionCreateExecute is a Collection proxy model hook of [OnModelCreateExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionCreateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterCreateSuccess is a proxy Collection model hook for [OnModelAfterCreateSuccess].
|
||||
// OnCollectionAfterCreateSuccess is a Collection proxy model hook of [OnModelAfterCreateSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionAfterCreateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterCreateError is a proxy Collection model hook for [OnModelAfterCreateError].
|
||||
// OnCollectionAfterCreateError is a Collection proxy model hook of [OnModelAfterCreateError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1068,28 +1149,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnCollectionUpdate is a proxy Collection model hook for [OnModelUpdate].
|
||||
// OnCollectionUpdate is a Collection proxy model hook of [OnModelUpdate].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionUpdate(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionUpdateExecute is a proxy Collection model hook for [OnModelUpdateExecute].
|
||||
// OnCollectionUpdateExecute is a Collection proxy model hook of [OnModelUpdateExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionUpdateExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterUpdateSuccess is a proxy Collection model hook for [OnModelAfterUpdateSuccess].
|
||||
// OnCollectionAfterUpdateSuccess is a Collection proxy model hook of [OnModelAfterUpdateSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionAfterUpdateSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterUpdateError is a proxy Collection model hook for [OnModelAfterUpdateError].
|
||||
// OnCollectionAfterUpdateError is a Collection proxy model hook of [OnModelAfterUpdateError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1098,28 +1179,28 @@ type App interface {
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnCollectionDelete is a proxy Collection model hook for [OnModelDelete].
|
||||
// OnCollectionDelete is a Collection proxy model hook of [OnModelDelete].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionDelete(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionDeleteExecute is a proxy Collection model hook for [OnModelDeleteExecute].
|
||||
// OnCollectionDeleteExecute is a Collection proxy model hook of [OnModelDeleteExecute].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionDeleteExecute(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterDeleteSuccess is a proxy Collection model hook for [OnModelAfterDeleteSuccess].
|
||||
// OnCollectionAfterDeleteSuccess is a Collection proxy model hook of [OnModelAfterDeleteSuccess].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnCollectionAfterDeleteSuccess(tags ...string) *hook.TaggedHook[*CollectionEvent]
|
||||
|
||||
// OnCollectionAfterDeleteError is a proxy Collection model hook for [OnModelAfterDeleteError].
|
||||
// OnCollectionAfterDeleteError is a Collection proxy model hook of [OnModelAfterDeleteError].
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1131,7 +1212,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]
|
||||
@@ -1187,7 +1268,7 @@ type App interface {
|
||||
|
||||
// OnRealtimeConnectRequest hook is triggered when establishing the SSE client connection.
|
||||
//
|
||||
// Any execution after [e.Next()] of a hook handler happens after the client disconnects.
|
||||
// Any execution after e.Next() of a hook handler happens after the client disconnects.
|
||||
OnRealtimeConnectRequest() *hook.Hook[*RealtimeConnectRequestEvent]
|
||||
|
||||
// OnRealtimeMessageSend hook is triggered when sending an SSE message to a client.
|
||||
@@ -1216,7 +1297,7 @@ type App interface {
|
||||
// OnSettingsReload hook is triggered every time when the App.Settings()
|
||||
// is being replaced with a new state.
|
||||
//
|
||||
// Calling App.Settings() after e.Next() should return the new state.
|
||||
// Calling App.Settings() after e.Next() returns the new state.
|
||||
OnSettingsReload() *hook.Hook[*SettingsReloadEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1229,8 +1310,12 @@ type App interface {
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadRequestEvent]
|
||||
|
||||
// OnFileBeforeTokenRequest hook is triggered on each file token API request.
|
||||
OnFileTokenRequest() *hook.Hook[*FileTokenRequestEvent]
|
||||
// OnFileBeforeTokenRequest hook is triggered on each auth file token API request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record Auth API event hooks
|
||||
@@ -1250,9 +1335,8 @@ type App interface {
|
||||
// OnRecordAuthWithPasswordRequest hook is triggered on each
|
||||
// Record auth with password API request.
|
||||
//
|
||||
// RecordAuthWithPasswordRequestEvent.Record could be nil if no
|
||||
// matching identity is found, allowing you to manually locate a different
|
||||
// Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
|
||||
// [RecordAuthWithPasswordRequestEvent.Record] could be nil if no matching identity is found, allowing
|
||||
// you to manually locate a different Record model (by reassigning [RecordAuthWithPasswordRequestEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -1262,7 +1346,7 @@ type App interface {
|
||||
// OnRecordAuthWithOAuth2Request hook is triggered on each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
|
||||
// If [RecordAuthWithOAuth2RequestEvent.Record] is not set, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
@@ -1353,6 +1437,9 @@ type App interface {
|
||||
// OnRecordRequestOTPRequest hook is triggered on each Record
|
||||
// request OTP API request.
|
||||
//
|
||||
// [RecordCreateOTPRequestEvent.Record] could be nil if no matching identity is found, allowing
|
||||
// you to manually create or locate a different Record model (by reassigning [RecordCreateOTPRequestEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
|
||||
+190
-56
@@ -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 (
|
||||
@@ -37,8 +38,12 @@ const (
|
||||
|
||||
LocalStorageDirName string = "storage"
|
||||
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"
|
||||
LocalNotifyDirName string = ".notify" // optional watched directory that is used as a cross-platform workaround for synchronizing various runtime states between multiple PocketBase instances pointing to the same pb_data
|
||||
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
|
||||
|
||||
// @todo consider removing after backups refactoring
|
||||
lostFoundDirName string = "lost+found"
|
||||
)
|
||||
|
||||
// FilesManager defines an interface with common methods that files manager models should implement.
|
||||
@@ -69,8 +74,8 @@ var _ App = (*BaseApp)(nil)
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
config *BaseAppConfig
|
||||
txInfo *txAppInfo
|
||||
store *store.Store[any]
|
||||
txInfo *TxAppInfo
|
||||
store *store.Store[string, any]
|
||||
cron *cron.Cron
|
||||
settings *Settings
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
@@ -194,7 +199,7 @@ type BaseApp struct {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
settings: newDefaultSettings(),
|
||||
store: store.New[any](nil),
|
||||
store: store.New[string, any](nil),
|
||||
cron: cron.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
config: &config,
|
||||
@@ -202,7 +207,7 @@ func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
|
||||
// apply config defaults
|
||||
if app.config.DBConnect == nil {
|
||||
app.config.DBConnect = dbConnect
|
||||
app.config.DBConnect = DefaultDBConnect
|
||||
}
|
||||
if app.config.DataMaxOpenConns <= 0 {
|
||||
app.config.DataMaxOpenConns = DefaultDataMaxOpenConns
|
||||
@@ -340,8 +345,6 @@ func (app *BaseApp) initHooks() {
|
||||
// NB! Note that using the returned app instance may cause data integrity errors
|
||||
// since the Record validations and data normalizations (including files uploads)
|
||||
// rely on the app hooks to work.
|
||||
//
|
||||
// @todo consider caching the created instance?
|
||||
func (app *BaseApp) UnsafeWithoutHooks() App {
|
||||
clone := *app
|
||||
|
||||
@@ -362,9 +365,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
|
||||
@@ -468,44 +479,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
|
||||
}
|
||||
@@ -534,7 +601,7 @@ func (app *BaseApp) Settings() *Settings {
|
||||
}
|
||||
|
||||
// Store returns the app runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
func (app *BaseApp) Store() *store.Store[string, any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
@@ -603,6 +670,14 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
fmt.Fprintf(mailLog, "├─ Attachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
if len(ae.Message.InlineAttachments) > 0 {
|
||||
attachmentKeys := make([]string, 0, len(ae.Message.InlineAttachments))
|
||||
for k := range ae.Message.InlineAttachments {
|
||||
attachmentKeys = append(attachmentKeys, k)
|
||||
}
|
||||
fmt.Fprintf(mailLog, "├─ InlineAttachments: %v\n", attachmentKeys)
|
||||
}
|
||||
|
||||
const indentation = " "
|
||||
if ae.Message.Text != "" {
|
||||
textParts := strings.Split(strings.TrimSpace(ae.Message.Text), "\n")
|
||||
@@ -653,7 +728,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
|
||||
@@ -701,7 +776,7 @@ func (app *BaseApp) Restart() error {
|
||||
}
|
||||
}()
|
||||
|
||||
return syscall.Exec(execPath, os.Args, os.Environ())
|
||||
return execve(execPath, os.Args, os.Environ())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -981,8 +1056,8 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
|
||||
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFileTokenRequest() *hook.Hook[*FileTokenRequestEvent] {
|
||||
return app.onFileTokenRequest
|
||||
func (app *BaseApp) OnFileTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenRequestEvent] {
|
||||
return hook.NewTaggedHook(app.onFileTokenRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -1133,26 +1208,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
|
||||
@@ -1185,6 +1259,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()
|
||||
@@ -1201,26 +1302,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()
|
||||
@@ -1238,8 +1357,18 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
Priority: 999,
|
||||
})
|
||||
|
||||
app.Cron().Add("__pbPragmaOptimize__", "0 0 * * *", func() {
|
||||
_, execErr := app.DB().NewQuery("PRAGMA optimize").Execute()
|
||||
app.Cron().Add("__pbDBOptimize__", "0 0 * * *", func() {
|
||||
_, execErr := app.NonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the main DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.AuxNonconcurrentDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA wal_checkpoint for the auxiliary DB", slog.String("error", execErr.Error()))
|
||||
}
|
||||
|
||||
_, execErr = app.NonconcurrentDB().NewQuery("PRAGMA optimize").Execute()
|
||||
if execErr != nil {
|
||||
app.Logger().Warn("Failed to run periodic PRAGMA optimize", slog.String("error", execErr.Error()))
|
||||
}
|
||||
@@ -1254,6 +1383,7 @@ func (app *BaseApp) registerBaseHooks() {
|
||||
app.registerMFAHooks()
|
||||
app.registerOTPHooks()
|
||||
app.registerAuthOriginHooks()
|
||||
app.registerNotifyWatcherHooks()
|
||||
}
|
||||
|
||||
// getLoggerMinLevel returns the logger min level based on the
|
||||
@@ -1280,7 +1410,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),
|
||||
@@ -1351,7 +1481,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()
|
||||
},
|
||||
|
||||
+123
-60
@@ -54,7 +54,13 @@ 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,
|
||||
LocalNotifyDirName,
|
||||
LocalAutocertCacheDirName,
|
||||
lostFoundDirName,
|
||||
}
|
||||
|
||||
return app.OnBackupCreate().Trigger(event, func(e *BackupEvent) error {
|
||||
// generate a default name if missing
|
||||
@@ -69,13 +75,18 @@ 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 {
|
||||
return txApp.AuxRunInTransaction(func(txApp App) error {
|
||||
// run manual checkpoint and truncate the WAL files
|
||||
// (errors are ignored because it is not that important and the PRAGMA may not be supported by the used driver)
|
||||
txApp.DB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
txApp.AuxDB().NewQuery("PRAGMA wal_checkpoint(TRUNCATE)").Execute()
|
||||
|
||||
return archive.Create(txApp.DataDir(), tempPath, e.Exclude...)
|
||||
})
|
||||
})
|
||||
@@ -133,10 +144,14 @@ 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
|
||||
// it is possible the restore to fail during the `os.Rename` operations
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/4647).
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
@@ -150,13 +165,20 @@ 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" {
|
||||
return errors.New("restore is not supported on Windows")
|
||||
}
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
fsys, err := e.App.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -165,76 +187,104 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
fsys.SetContext(e.Context)
|
||||
|
||||
// fetch the backup file in a temp location
|
||||
br, err := fsys.GetFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(e.App.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
if ok, _ := fsys.Exists(name); !ok {
|
||||
return fmt.Errorf("missing or invalid backup file %q to restore", name)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
|
||||
if _, err := io.Copy(tempZip, br); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
|
||||
// extract the zip
|
||||
if e.App.Settings().Backups.S3.Enabled {
|
||||
br, err := fsys.GetReader(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
|
||||
|
||||
_, err = io.Copy(tempZip, br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = archive.Extract(tempZip.Name(), extractedDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove the temp zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
_ = tempZip.Close()
|
||||
err = os.Remove(tempZip.Name())
|
||||
if err != nil {
|
||||
e.App.Logger().Warn(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.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(e.App.DataDir(), LocalBackupsDirName, filepath.Base(name))
|
||||
|
||||
err = archive.Extract(zipPath, extractedDataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ensure that a database file exists
|
||||
// ensure that at least a database file exists
|
||||
extractedDB := filepath.Join(extractedDataDir, "data.db")
|
||||
if _, err := os.Stat(extractedDB); err != nil {
|
||||
return fmt.Errorf("data.db file is missing or invalid: %w", err)
|
||||
}
|
||||
|
||||
// remove the extracted zip file since we no longer need it
|
||||
// (this is in case the app restarts and the defer calls are not called)
|
||||
if err := os.Remove(tempZip.Name()); err != nil {
|
||||
e.App.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
|
||||
|
||||
// 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(4))
|
||||
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)
|
||||
}
|
||||
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, e.App.DataDir(), e.Exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the extracted archive content to pb_data: %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
|
||||
@@ -252,7 +302,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// registerAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) registerAutobackupHooks() {
|
||||
const jobId = "__auto_pb_backup__"
|
||||
const jobId = "__pbAutoBackup__"
|
||||
|
||||
loadJob := func() {
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
@@ -272,6 +322,19 @@ func (app *BaseApp) registerAutobackupHooks() {
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
|
||||
alertError := sendSystemAlertToAllSuperusers(
|
||||
app,
|
||||
"Autobackup failure",
|
||||
"Failed to create/upload automated backup. Raw error:\n"+err.Error(),
|
||||
)
|
||||
if alertError != nil {
|
||||
app.Logger().Warn(
|
||||
"[Backup cron] Failed to send backup error alerts",
|
||||
slog.String("name", name),
|
||||
slog.String("error", alertError.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
+207
-9
@@ -2,13 +2,16 @@ package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
@@ -97,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},
|
||||
@@ -114,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},
|
||||
@@ -126,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)
|
||||
|
||||
@@ -139,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) {
|
||||
@@ -351,6 +375,12 @@ func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// silence query logs
|
||||
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) {}
|
||||
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
@@ -370,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)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@ func (app *BaseApp) ImportCollections(toImport []map[string]any, deleteMissing b
|
||||
continue
|
||||
}
|
||||
if imported.Fields.GetById(f.GetId()) == nil {
|
||||
// replace with the existing id to prevent accidental column deletion
|
||||
// since otherwise the imported field will be treated as a new one
|
||||
found := imported.Fields.GetByName(f.GetName())
|
||||
if found != nil && found.Type() == f.Type() {
|
||||
found.SetId(f.GetId())
|
||||
}
|
||||
imported.Fields.Add(f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+244
-93
@@ -3,8 +3,10 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -26,15 +28,20 @@ const (
|
||||
|
||||
const systemHookIdCollection = "__pbCollectionSystemHook__"
|
||||
|
||||
const defaultLowercaseRecordIdPattern = "^[a-z0-9]+$"
|
||||
|
||||
func (app *BaseApp) registerCollectionHooks() {
|
||||
app.OnModelValidate().Bind(&hook.Handler[*ModelEvent]{
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionValidate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -46,10 +53,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -61,10 +71,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionCreateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -76,10 +89,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -91,10 +107,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterCreateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -106,10 +125,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdate().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -121,10 +143,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionUpdateExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -136,10 +161,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -151,10 +179,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterUpdateError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -166,10 +197,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDelete().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -181,10 +215,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionDeleteExecute().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -196,10 +233,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelEvent) error {
|
||||
if ce, ok := newCollectionEventFromModelEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteSuccess().Trigger(ce, func(ce *CollectionEvent) error {
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
defer syncCollectionEventWithModelEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelEventWithCollectionEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -211,10 +251,13 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
Id: systemHookIdCollection,
|
||||
Func: func(me *ModelErrorEvent) error {
|
||||
if ce, ok := newCollectionErrorEventFromModelErrorEvent(me); ok {
|
||||
return me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
err := me.App.OnCollectionAfterDeleteError().Trigger(ce, func(ce *CollectionErrorEvent) error {
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
defer syncCollectionErrorEventWithModelErrorEvent(ce, me)
|
||||
return me.Next()
|
||||
})
|
||||
syncModelErrorEventWithCollectionErrorEvent(me, ce)
|
||||
return err
|
||||
}
|
||||
|
||||
return me.Next()
|
||||
@@ -305,10 +348,12 @@ func (app *BaseApp) registerCollectionHooks() {
|
||||
}
|
||||
|
||||
// @todo experiment eventually replacing the rules *string with a struct?
|
||||
// @todo consider changing the Indexes field to a "getter" for the sqlite_master table?
|
||||
type baseCollection struct {
|
||||
BaseModel
|
||||
|
||||
disableIntegrityChecks bool
|
||||
autogeneratedId string
|
||||
|
||||
ListRule *string `db:"listRule" json:"listRule" form:"listRule"`
|
||||
ViewRule *string `db:"viewRule" json:"viewRule" form:"viewRule"`
|
||||
@@ -325,9 +370,12 @@ type baseCollection struct {
|
||||
Type string `db:"type" json:"type" form:"type"`
|
||||
Fields FieldsList `db:"fields" json:"fields" form:"fields"`
|
||||
Indexes types.JSONArray[string] `db:"indexes" json:"indexes" form:"indexes"`
|
||||
System bool `db:"system" json:"system" form:"system"`
|
||||
Created types.DateTime `db:"created" json:"created"`
|
||||
Updated types.DateTime `db:"updated" json:"updated"`
|
||||
|
||||
// System prevents the collection rename, deletion and rules change.
|
||||
// It is used primarily for internal purposes for collections like "_superusers", "_externalAuths", etc.
|
||||
System bool `db:"system" json:"system" form:"system"`
|
||||
}
|
||||
|
||||
// Collection defines the table, fields and various options related to a set of records.
|
||||
@@ -338,45 +386,78 @@ type Collection struct {
|
||||
}
|
||||
|
||||
// NewCollection initializes and returns a new Collection model with the specified type and name.
|
||||
func NewCollection(typ, name string) *Collection {
|
||||
//
|
||||
// It also loads the minimal default configuration for the collection
|
||||
// (eg. system fields, indexes, type specific options, etc.).
|
||||
func NewCollection(typ, name string, optId ...string) *Collection {
|
||||
switch typ {
|
||||
case CollectionTypeAuth:
|
||||
return NewAuthCollection(name)
|
||||
return NewAuthCollection(name, optId...)
|
||||
case CollectionTypeView:
|
||||
return NewViewCollection(name)
|
||||
return NewViewCollection(name, optId...)
|
||||
default:
|
||||
return NewBaseCollection(name)
|
||||
return NewBaseCollection(name, optId...)
|
||||
}
|
||||
}
|
||||
|
||||
// NewBaseCollection initializes and returns a new "base" Collection model.
|
||||
func NewBaseCollection(name string) *Collection {
|
||||
//
|
||||
// It also loads the minimal default configuration for the collection
|
||||
// (eg. system fields, indexes, type specific options, etc.).
|
||||
func NewBaseCollection(name string, optId ...string) *Collection {
|
||||
m := &Collection{}
|
||||
m.Name = name
|
||||
m.Type = CollectionTypeBase
|
||||
|
||||
// @todo consider removing once inferred composite literals are supported
|
||||
if len(optId) > 0 {
|
||||
m.Id = optId[0]
|
||||
}
|
||||
|
||||
m.initDefaultId()
|
||||
m.initDefaultFields()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewViewCollection initializes and returns a new "view" Collection model.
|
||||
func NewViewCollection(name string) *Collection {
|
||||
//
|
||||
// It also loads the minimal default configuration for the collection
|
||||
// (eg. system fields, indexes, type specific options, etc.).
|
||||
func NewViewCollection(name string, optId ...string) *Collection {
|
||||
m := &Collection{}
|
||||
m.Name = name
|
||||
m.Type = CollectionTypeView
|
||||
|
||||
// @todo consider removing once inferred composite literals are supported
|
||||
if len(optId) > 0 {
|
||||
m.Id = optId[0]
|
||||
}
|
||||
|
||||
m.initDefaultId()
|
||||
m.initDefaultFields()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// NewAuthCollection initializes and returns a new "auth" Collection model.
|
||||
func NewAuthCollection(name string) *Collection {
|
||||
//
|
||||
// It also loads the minimal default configuration for the collection
|
||||
// (eg. system fields, indexes, type specific options, etc.).
|
||||
func NewAuthCollection(name string, optId ...string) *Collection {
|
||||
m := &Collection{}
|
||||
m.Name = name
|
||||
m.Type = CollectionTypeAuth
|
||||
|
||||
// @todo consider removing once inferred composite literals are supported
|
||||
if len(optId) > 0 {
|
||||
m.Id = optId[0]
|
||||
}
|
||||
|
||||
m.initDefaultId()
|
||||
m.initDefaultFields()
|
||||
m.setDefaultAuthOptions()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -449,12 +530,13 @@ func (m *Collection) UnmarshalJSON(b []byte) error {
|
||||
minimal := &struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Id string `json:"id"`
|
||||
}{}
|
||||
if err := json.Unmarshal(b, minimal); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blank := NewCollection(minimal.Type, minimal.Name)
|
||||
blank := NewCollection(minimal.Type, minimal.Name, minimal.Id)
|
||||
*m = *blank
|
||||
}
|
||||
|
||||
@@ -464,7 +546,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:
|
||||
@@ -478,19 +560,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)
|
||||
@@ -656,40 +745,99 @@ func onCollectionDeleteExecute(e *CollectionEvent) error {
|
||||
// save hook
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (c *Collection) idChecksum() string {
|
||||
return "pbc_" + crc32Checksum(c.Type+c.Name)
|
||||
}
|
||||
|
||||
func (c *Collection) initDefaultId() {
|
||||
if c.Id == "" && c.Name != "" {
|
||||
c.Id = "_pbc_" + crc32Checksum(c.Name)
|
||||
if c.Id == "" {
|
||||
c.Id = c.idChecksum()
|
||||
c.autogeneratedId = c.Id
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collection) savePrepare() error {
|
||||
if c.Type == "" {
|
||||
c.Type = CollectionTypeBase
|
||||
func (c *Collection) updateGeneratedIdIfExists(app App) {
|
||||
if !c.IsNew() ||
|
||||
// the id was explicitly cleared
|
||||
c.Id == "" ||
|
||||
// the id was manually set
|
||||
c.Id != c.autogeneratedId {
|
||||
return
|
||||
}
|
||||
|
||||
if c.IsNew() {
|
||||
c.initDefaultId()
|
||||
c.Created = types.NowDateTime()
|
||||
// generate an up-to-date checksum
|
||||
newId := c.idChecksum()
|
||||
|
||||
// add a number to the current id (if already exists)
|
||||
for i := 2; i < 1000; i++ {
|
||||
var exists int
|
||||
_ = app.CollectionQuery().Select("(1)").AndWhere(dbx.HashExp{"id": newId}).Limit(1).Row(&exists)
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
newId = c.idChecksum() + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
c.Updated = types.NowDateTime()
|
||||
|
||||
// recreate the fields list to ensure that all normalizations
|
||||
// like default field id are applied
|
||||
c.Fields = NewFieldsList(c.Fields...)
|
||||
|
||||
c.initDefaultFields()
|
||||
|
||||
if c.IsAuth() {
|
||||
c.unsetMissingOAuth2MappedFields()
|
||||
// no change
|
||||
if c.Id == newId {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
// replace the old id in the index names (if any)
|
||||
for i, idx := range c.Indexes {
|
||||
parsed := dbutils.ParseIndex(idx)
|
||||
original := parsed.IndexName
|
||||
parsed.IndexName = strings.ReplaceAll(parsed.IndexName, c.Id, newId)
|
||||
if parsed.IndexName != original {
|
||||
c.Indexes[i] = parsed.Build()
|
||||
}
|
||||
}
|
||||
|
||||
// update model id
|
||||
c.Id = newId
|
||||
}
|
||||
|
||||
func onCollectionSave(e *CollectionEvent) error {
|
||||
if err := e.Collection.savePrepare(); err != nil {
|
||||
return err
|
||||
if e.Collection.Type == "" {
|
||||
e.Collection.Type = CollectionTypeBase
|
||||
}
|
||||
|
||||
if e.Collection.IsNew() {
|
||||
e.Collection.initDefaultId()
|
||||
e.Collection.Created = types.NowDateTime()
|
||||
}
|
||||
|
||||
e.Collection.Updated = types.NowDateTime()
|
||||
|
||||
// recreate the fields list to ensure that all normalizations
|
||||
// like default field id are applied
|
||||
e.Collection.Fields = NewFieldsList(e.Collection.Fields...)
|
||||
|
||||
e.Collection.initDefaultFields()
|
||||
|
||||
if e.Collection.IsAuth() {
|
||||
e.Collection.unsetMissingOAuth2MappedFields()
|
||||
}
|
||||
|
||||
e.Collection.updateGeneratedIdIfExists(e.App)
|
||||
|
||||
// normalize indexes table name
|
||||
for i, raw := range e.Collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
// no need to normalize
|
||||
if parsed.TableName == e.Collection.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed.TableName = e.Collection.Name
|
||||
|
||||
normalized := parsed.Build()
|
||||
if normalized == "" {
|
||||
continue // leave to the model validator to decide whether to return an error
|
||||
}
|
||||
|
||||
e.Collection.Indexes[i] = normalized
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
@@ -783,24 +931,24 @@ func onCollectionSaveExecute(e *CollectionEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Collection) initDefaultFields() {
|
||||
switch m.Type {
|
||||
func (c *Collection) initDefaultFields() {
|
||||
switch c.Type {
|
||||
case CollectionTypeBase:
|
||||
m.initIdField()
|
||||
c.initIdField()
|
||||
case CollectionTypeAuth:
|
||||
m.initIdField()
|
||||
m.initPasswordField()
|
||||
m.initTokenKeyField()
|
||||
m.initEmailField()
|
||||
m.initEmailVisibilityField()
|
||||
m.initVerifiedField()
|
||||
c.initIdField()
|
||||
c.initPasswordField()
|
||||
c.initTokenKeyField()
|
||||
c.initEmailField()
|
||||
c.initEmailVisibilityField()
|
||||
c.initVerifiedField()
|
||||
case CollectionTypeView:
|
||||
// view fields are autogenerated
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initIdField() {
|
||||
field, _ := m.Fields.GetByName(FieldNameId).(*TextField)
|
||||
func (c *Collection) initIdField() {
|
||||
field, _ := c.Fields.GetByName(FieldNameId).(*TextField)
|
||||
if field == nil {
|
||||
// create default field
|
||||
field = &TextField{
|
||||
@@ -810,26 +958,29 @@ func (m *Collection) initIdField() {
|
||||
Required: true,
|
||||
Min: 15,
|
||||
Max: 15,
|
||||
Pattern: `^[a-z0-9]+$`,
|
||||
Pattern: defaultLowercaseRecordIdPattern,
|
||||
AutogeneratePattern: `[a-z0-9]{15}`,
|
||||
}
|
||||
|
||||
// prepend it
|
||||
m.Fields = NewFieldsList(append([]Field{field}, m.Fields...)...)
|
||||
c.Fields = NewFieldsList(append([]Field{field}, c.Fields...)...)
|
||||
} else {
|
||||
// enforce system defaults
|
||||
field.System = true
|
||||
field.Required = true
|
||||
field.PrimaryKey = true
|
||||
field.Hidden = false
|
||||
if field.Pattern == "" {
|
||||
field.Pattern = defaultLowercaseRecordIdPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initPasswordField() {
|
||||
field, _ := m.Fields.GetByName(FieldNamePassword).(*PasswordField)
|
||||
func (c *Collection) initPasswordField() {
|
||||
field, _ := c.Fields.GetByName(FieldNamePassword).(*PasswordField)
|
||||
if field == nil {
|
||||
// load default field
|
||||
m.Fields.Add(&PasswordField{
|
||||
c.Fields.Add(&PasswordField{
|
||||
Name: FieldNamePassword,
|
||||
System: true,
|
||||
Hidden: true,
|
||||
@@ -844,11 +995,11 @@ func (m *Collection) initPasswordField() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initTokenKeyField() {
|
||||
field, _ := m.Fields.GetByName(FieldNameTokenKey).(*TextField)
|
||||
func (c *Collection) initTokenKeyField() {
|
||||
field, _ := c.Fields.GetByName(FieldNameTokenKey).(*TextField)
|
||||
if field == nil {
|
||||
// load default field
|
||||
m.Fields.Add(&TextField{
|
||||
c.Fields.Add(&TextField{
|
||||
Name: FieldNameTokenKey,
|
||||
System: true,
|
||||
Hidden: true,
|
||||
@@ -865,21 +1016,21 @@ func (m *Collection) initTokenKeyField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameTokenKey, m.Indexes) {
|
||||
m.Indexes = append(m.Indexes, fmt.Sprintf(
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameTokenKey); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`)",
|
||||
m.fieldIndexName(FieldNameTokenKey),
|
||||
m.Name,
|
||||
c.fieldIndexName(FieldNameTokenKey),
|
||||
c.Name,
|
||||
FieldNameTokenKey,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initEmailField() {
|
||||
field, _ := m.Fields.GetByName(FieldNameEmail).(*EmailField)
|
||||
func (c *Collection) initEmailField() {
|
||||
field, _ := c.Fields.GetByName(FieldNameEmail).(*EmailField)
|
||||
if field == nil {
|
||||
// load default field
|
||||
m.Fields.Add(&EmailField{
|
||||
c.Fields.Add(&EmailField{
|
||||
Name: FieldNameEmail,
|
||||
System: true,
|
||||
Required: true,
|
||||
@@ -891,22 +1042,22 @@ func (m *Collection) initEmailField() {
|
||||
}
|
||||
|
||||
// ensure that there is a unique index for the email field
|
||||
if !dbutils.HasSingleColumnUniqueIndex(FieldNameEmail, m.Indexes) {
|
||||
m.Indexes = append(m.Indexes, fmt.Sprintf(
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(c.Indexes, FieldNameEmail); !ok {
|
||||
c.Indexes = append(c.Indexes, fmt.Sprintf(
|
||||
"CREATE UNIQUE INDEX `%s` ON `%s` (`%s`) WHERE `%s` != ''",
|
||||
m.fieldIndexName(FieldNameEmail),
|
||||
m.Name,
|
||||
c.fieldIndexName(FieldNameEmail),
|
||||
c.Name,
|
||||
FieldNameEmail,
|
||||
FieldNameEmail,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initEmailVisibilityField() {
|
||||
field, _ := m.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
|
||||
func (c *Collection) initEmailVisibilityField() {
|
||||
field, _ := c.Fields.GetByName(FieldNameEmailVisibility).(*BoolField)
|
||||
if field == nil {
|
||||
// load default field
|
||||
m.Fields.Add(&BoolField{
|
||||
c.Fields.Add(&BoolField{
|
||||
Name: FieldNameEmailVisibility,
|
||||
System: true,
|
||||
})
|
||||
@@ -916,11 +1067,11 @@ func (m *Collection) initEmailVisibilityField() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) initVerifiedField() {
|
||||
field, _ := m.Fields.GetByName(FieldNameVerified).(*BoolField)
|
||||
func (c *Collection) initVerifiedField() {
|
||||
field, _ := c.Fields.GetByName(FieldNameVerified).(*BoolField)
|
||||
if field == nil {
|
||||
// load default field
|
||||
m.Fields.Add(&BoolField{
|
||||
c.Fields.Add(&BoolField{
|
||||
Name: FieldNameVerified,
|
||||
System: true,
|
||||
})
|
||||
@@ -930,15 +1081,15 @@ func (m *Collection) initVerifiedField() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Collection) fieldIndexName(field string) string {
|
||||
func (c *Collection) fieldIndexName(field string) string {
|
||||
name := "idx_" + field + "_"
|
||||
|
||||
if m.Id != "" {
|
||||
name += m.Id
|
||||
} else if m.Name != "" {
|
||||
name += m.Name
|
||||
if c.Id != "" {
|
||||
name += c.Id
|
||||
} else if c.Name != "" {
|
||||
name += c.Name
|
||||
} else {
|
||||
name += security.PseudorandomString(10)
|
||||
name += security.PseudorandomStringWithAlphabet(10, DefaultIdAlphabet)
|
||||
}
|
||||
|
||||
if len(name) > 64 {
|
||||
|
||||
@@ -60,7 +60,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
MFA: MFAConfig{
|
||||
Enabled: false,
|
||||
Duration: 1800, // 30min
|
||||
Duration: 600, // 10min
|
||||
},
|
||||
OTP: OTPConfig{
|
||||
Enabled: false,
|
||||
@@ -70,7 +70,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
AuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 604800, // 7 days
|
||||
Duration: 432000, // 5days
|
||||
},
|
||||
PasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -82,7 +82,7 @@ func (m *Collection) setDefaultAuthOptions() {
|
||||
},
|
||||
VerificationToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 259200, // 3days
|
||||
Duration: 86400, // 1day
|
||||
},
|
||||
FileToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
@@ -122,10 +122,13 @@ type collectionAuthOptions struct {
|
||||
// and which OAuth2 providers are allowed.
|
||||
OAuth2 OAuth2Config `form:"oauth2" json:"oauth2"`
|
||||
|
||||
// PasswordAuth defines options related to the collection password authentication.
|
||||
PasswordAuth PasswordAuthConfig `form:"passwordAuth" json:"passwordAuth"`
|
||||
|
||||
// MFA defines options related to the Multi-factor authentication (MFA).
|
||||
MFA MFAConfig `form:"mfa" json:"mfa"`
|
||||
|
||||
// OTP defines options related to the One-time password authentication (OTP).
|
||||
OTP OTPConfig `form:"otp" json:"otp"`
|
||||
|
||||
// Various token configurations
|
||||
@@ -136,7 +139,7 @@ type collectionAuthOptions struct {
|
||||
VerificationToken TokenConfig `form:"verificationToken" json:"verificationToken"`
|
||||
FileToken TokenConfig `form:"fileToken" json:"fileToken"`
|
||||
|
||||
// default email templates
|
||||
// Default email templates
|
||||
// ---
|
||||
VerificationTemplate EmailTemplate `form:"verificationTemplate" json:"verificationTemplate"`
|
||||
ResetPasswordTemplate EmailTemplate `form:"resetPasswordTemplate" json:"resetPasswordTemplate"`
|
||||
@@ -442,7 +445,7 @@ func checkForDuplicatedProviders(value any) error {
|
||||
if _, ok := existing[c.Name]; ok {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"name": validation.NewError("validation_duplicated_provider", "The provider "+c.Name+" is already registered.").
|
||||
"name": validation.NewError("validation_duplicated_provider", "The provider {{.name}} is already registered.").
|
||||
SetParams(map[string]any{"name": c.Name}),
|
||||
},
|
||||
}
|
||||
@@ -490,7 +493,7 @@ func checkProviderName(value any) error {
|
||||
}
|
||||
|
||||
if _, err := auth.NewProviderByName(name); err != nil {
|
||||
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name "+name+".").
|
||||
return validation.NewError("validation_missing_provider", "Invalid or missing provider with name {{.name}}.").
|
||||
SetParams(map[string]any{"name": name})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
@@ -17,6 +18,7 @@ var defaultVerificationTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Verify</a>
|
||||
</p>
|
||||
<p><i>If you didn't recently register, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
@@ -30,7 +32,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Reset password</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>
|
||||
<p><i>If you didn't ask to reset your password, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
@@ -44,7 +46,7 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
|
||||
<p>
|
||||
<a class="btn" href="` + EmailPlaceholderAppURL + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken + `" target="_blank" rel="noopener">Confirm new email</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>
|
||||
<p><i>If you didn't ask to change your email address, please ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
@@ -65,9 +67,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
|
||||
|
||||
+468
-86
@@ -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"
|
||||
)
|
||||
|
||||
@@ -26,7 +30,7 @@ func TestNewCollection(t *testing.T) {
|
||||
"",
|
||||
"",
|
||||
[]string{
|
||||
`"id":""`,
|
||||
`"id":"pbc_`,
|
||||
`"name":""`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
@@ -45,7 +49,7 @@ func TestNewCollection(t *testing.T) {
|
||||
"unknown",
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
@@ -64,7 +68,7 @@ func TestNewCollection(t *testing.T) {
|
||||
"base",
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
@@ -83,7 +87,7 @@ func TestNewCollection(t *testing.T) {
|
||||
"view",
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"view"`,
|
||||
`"indexes":[]`,
|
||||
@@ -100,7 +104,7 @@ func TestNewCollection(t *testing.T) {
|
||||
"auth",
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"auth"`,
|
||||
`"fields":[{`,
|
||||
@@ -148,7 +152,7 @@ func TestNewBaseCollection(t *testing.T) {
|
||||
{
|
||||
"",
|
||||
[]string{
|
||||
`"id":""`,
|
||||
`"id":"pbc_`,
|
||||
`"name":""`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
@@ -166,7 +170,7 @@ func TestNewBaseCollection(t *testing.T) {
|
||||
{
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
@@ -206,7 +210,7 @@ func TestNewViewCollection(t *testing.T) {
|
||||
{
|
||||
"",
|
||||
[]string{
|
||||
`"id":""`,
|
||||
`"id":"pbc_`,
|
||||
`"name":""`,
|
||||
`"type":"view"`,
|
||||
`"indexes":[]`,
|
||||
@@ -222,7 +226,7 @@ func TestNewViewCollection(t *testing.T) {
|
||||
{
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"view"`,
|
||||
`"indexes":[]`,
|
||||
@@ -286,7 +290,7 @@ func TestNewAuthCollection(t *testing.T) {
|
||||
{
|
||||
"test",
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"auth"`,
|
||||
`"fields":[{`,
|
||||
@@ -498,10 +502,11 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
raw string
|
||||
collection func() *core.Collection
|
||||
expectedCollection func() *core.Collection
|
||||
name string
|
||||
raw string
|
||||
collection func() *core.Collection
|
||||
expected []string
|
||||
notExpected []string
|
||||
}{
|
||||
{
|
||||
"base new empty",
|
||||
@@ -509,12 +514,18 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
func() *core.Collection {
|
||||
return &core.Collection{}
|
||||
},
|
||||
func() *core.Collection {
|
||||
c := core.NewBaseCollection("test")
|
||||
c.ListRule = types.Pointer("1=2")
|
||||
c.AuthRule = types.Pointer("1=3")
|
||||
c.ViewQuery = "abc"
|
||||
return c
|
||||
[]string{
|
||||
`"type":"base"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"listRule":"1=2"`,
|
||||
`"fields":[`,
|
||||
`"name":"id"`,
|
||||
`"indexes":[]`,
|
||||
},
|
||||
[]string{
|
||||
`"authRule":"1=3"`,
|
||||
`"viewQuery":"abc"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -523,12 +534,17 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
func() *core.Collection {
|
||||
return &core.Collection{}
|
||||
},
|
||||
func() *core.Collection {
|
||||
c := core.NewViewCollection("test")
|
||||
c.ListRule = types.Pointer("1=2")
|
||||
c.AuthRule = types.Pointer("1=3")
|
||||
c.ViewQuery = "abc"
|
||||
return c
|
||||
[]string{
|
||||
`"type":"view"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"listRule":"1=2"`,
|
||||
`"fields":[]`,
|
||||
`"viewQuery":"abc"`,
|
||||
`"indexes":[]`,
|
||||
},
|
||||
[]string{
|
||||
`"authRule":"1=3"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -537,12 +553,18 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
func() *core.Collection {
|
||||
return &core.Collection{}
|
||||
},
|
||||
func() *core.Collection {
|
||||
c := core.NewAuthCollection("test")
|
||||
c.ListRule = types.Pointer("1=2")
|
||||
c.AuthRule = types.Pointer("1=3")
|
||||
c.ViewQuery = "abc"
|
||||
return c
|
||||
[]string{
|
||||
`"type":"auth"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"listRule":"1=2"`,
|
||||
`"authRule":"1=3"`,
|
||||
`"fields":[`,
|
||||
`"name":"id"`,
|
||||
},
|
||||
[]string{
|
||||
`"indexes":[]`,
|
||||
`"viewQuery":"abc"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -553,14 +575,16 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
c.Type = core.CollectionTypeBase
|
||||
return c
|
||||
},
|
||||
func() *core.Collection {
|
||||
c := &core.Collection{}
|
||||
c.Type = core.CollectionTypeBase
|
||||
c.Name = "test"
|
||||
c.ListRule = types.Pointer("1=2")
|
||||
c.AuthRule = types.Pointer("1=3")
|
||||
c.ViewQuery = "abc"
|
||||
return c
|
||||
[]string{
|
||||
`"type":"base"`,
|
||||
`"id":""`,
|
||||
`"name":"test"`,
|
||||
`"listRule":"1=2"`,
|
||||
`"fields":[]`,
|
||||
},
|
||||
[]string{
|
||||
`"authRule":"1=3"`,
|
||||
`"viewQuery":"abc"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -570,14 +594,17 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
c, _ := app.FindCollectionByNameOrId("demo1")
|
||||
return c
|
||||
},
|
||||
func() *core.Collection {
|
||||
c, _ := app.FindCollectionByNameOrId("demo1")
|
||||
c.Type = core.CollectionTypeAuth
|
||||
c.Name = "test"
|
||||
c.ListRule = types.Pointer("1=2")
|
||||
c.AuthRule = types.Pointer("1=3")
|
||||
c.ViewQuery = "abc"
|
||||
return c
|
||||
[]string{
|
||||
`"type":"auth"`,
|
||||
`"name":"test"`,
|
||||
`"listRule":"1=2"`,
|
||||
`"authRule":"1=3"`,
|
||||
`"fields":[`,
|
||||
`"name":"id"`,
|
||||
},
|
||||
[]string{
|
||||
`"name":"tokenKey"`,
|
||||
`"viewQuery":"abc"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -597,14 +624,16 @@ func TestCollectionUnmarshalJSON(t *testing.T) {
|
||||
}
|
||||
rawResultStr := string(rawResult)
|
||||
|
||||
rawExpected, err := json.Marshal(s.expectedCollection())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, part := range s.expected {
|
||||
if !strings.Contains(rawResultStr, part) {
|
||||
t.Fatalf("Missing expected %q in\n%v", part, rawResultStr)
|
||||
}
|
||||
}
|
||||
rawExpectedStr := string(rawExpected)
|
||||
|
||||
if rawResultStr != rawExpectedStr {
|
||||
t.Fatalf("Expected collection\n%s\ngot\n%s", rawExpectedStr, rawResultStr)
|
||||
for _, part := range s.notExpected {
|
||||
if strings.Contains(rawResultStr, part) {
|
||||
t.Fatalf("Didn't expected %q in\n%v", part, rawResultStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -630,7 +659,7 @@ func TestCollectionSerialize(t *testing.T) {
|
||||
return c
|
||||
},
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -658,7 +687,7 @@ func TestCollectionSerialize(t *testing.T) {
|
||||
return c
|
||||
},
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"view"`,
|
||||
`"viewQuery":"1=1"`,
|
||||
@@ -686,7 +715,7 @@ func TestCollectionSerialize(t *testing.T) {
|
||||
return c
|
||||
},
|
||||
[]string{
|
||||
`"id":"_pbc_3632233996"`,
|
||||
`"id":"pbc_`,
|
||||
`"name":"test"`,
|
||||
`"type":"auth"`,
|
||||
`"oauth2":{`,
|
||||
@@ -731,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()
|
||||
|
||||
@@ -748,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":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","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":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","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":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","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":"bool597745380","name":"f1","presentable":false,"required":false,"system":true,"type":"bool"},{"hidden":false,"id":"bool3131674462","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"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -782,8 +851,8 @@ func TestCollectionDBExport(t *testing.T) {
|
||||
c.Updated = date
|
||||
c.Indexes = types.JSONArray[string]{"CREATE INDEX idx1 on test_name(id)", "CREATE INDEX idx2 on test_name(id)"}
|
||||
c.ViewQuery = "select 1"
|
||||
c.Fields.Add(&core.BoolField{Name: "f1", System: true})
|
||||
c.Fields.Add(&core.BoolField{Name: "f2", Required: true})
|
||||
c.Fields.Add(&core.BoolField{Id: "f1_id", Name: "f1", System: true})
|
||||
c.Fields.Add(&core.BoolField{Id: "f2_id", Name: "f2", Required: true})
|
||||
c.RawOptions = types.JSONRaw(`{"viewQuery": "select 2"}`) // should be ignored
|
||||
|
||||
result, err := c.DBExport(app)
|
||||
@@ -951,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()
|
||||
|
||||
@@ -1233,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",
|
||||
@@ -1320,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 {
|
||||
@@ -1333,3 +1678,40 @@ func TestCollectionSaveViewWrapping(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionSaveIndexesTableNameNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
dummyCollection := core.NewBaseCollection("new_test")
|
||||
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
|
||||
dummyCollection.Indexes = []string{
|
||||
"create index `new_test_idx1` on `` (`test`) where 1=1",
|
||||
"create index `new_test_idx2` on `test` (`test`) where 1=2",
|
||||
"create index `new_test_idx3` on `someting_else` (`test`) where 1=3",
|
||||
}
|
||||
|
||||
err := app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// refetch a clean state
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(dummyCollection.Indexes) != 3 {
|
||||
t.Fatalf("Expected 3 indexes, got %v", dummyCollection.Indexes)
|
||||
}
|
||||
|
||||
for _, raw := range dummyCollection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
if parsed.TableName != dummyCollection.Name {
|
||||
t.Fatalf("Expected all indexes to have tableName %q, found %q:\n%s", dummyCollection.Name, parsed.TableName, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+73
-30
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -37,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
|
||||
}
|
||||
@@ -75,16 +76,16 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
|
||||
// FindCachedCollectionByNameOrId is similar to [BaseApp.FindCollectionByNameOrId]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// Returns [sql.ErrNoRows] if no Collection is found for consistency
|
||||
// with the [App.FindCollectionByNameOrId] method.
|
||||
// with the [BaseApp.FindCollectionByNameOrId] method.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [App.FindCollectionByNameOrId] instead.
|
||||
// use [BaseApp.FindCollectionByNameOrId] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
@@ -94,7 +95,7 @@ func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, erro
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [App.ReloadCachedCollections()]
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
@@ -111,30 +112,6 @@ func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindCollectionReferences returns information for all relation fields
|
||||
// referencing the provided collection.
|
||||
//
|
||||
@@ -168,6 +145,72 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindCachedCollectionReferences is similar to [BaseApp.FindCollectionReferences]
|
||||
// but retrieves the Collection from the app cache instead of making a db call.
|
||||
//
|
||||
// NB! This method is suitable for read-only Collection operations.
|
||||
//
|
||||
// If you plan making changes to the returned Collection model,
|
||||
// use [BaseApp.FindCollectionReferences] instead.
|
||||
//
|
||||
// Caveats:
|
||||
//
|
||||
// - The returned Collection should be used only for read-only operations.
|
||||
// Avoid directly modifying the returned cached Collection as it will affect
|
||||
// the global cached value even if you don't persist the changes in the database!
|
||||
// - If you are updating a Collection in a transaction and then call this method before commit,
|
||||
// it'll return the cached Collection state and not the one from the uncommitted transaction.
|
||||
// - The cache is automatically updated on collections db change (create/update/delete).
|
||||
// To manually reload the cache you can call [BaseApp.ReloadCachedCollections].
|
||||
func (app *BaseApp) FindCachedCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
|
||||
collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
|
||||
if collections == nil {
|
||||
// cache is not initialized yet (eg. run in a system migration)
|
||||
return app.FindCollectionReferences(collection, excludeIds...)
|
||||
}
|
||||
|
||||
result := map[*Collection][]Field{}
|
||||
|
||||
for _, c := range collections {
|
||||
if slices.Contains(excludeIds, c.Id) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rawField := range c.Fields {
|
||||
f, ok := rawField.(*RelationField)
|
||||
if ok && f.CollectionId == collection.Id {
|
||||
result[c] = append(result[c], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsCollectionNameUnique checks that there is no existing collection
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case insensitive check because the name is used also as
|
||||
// table name for the records.
|
||||
func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
query := app.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1)
|
||||
|
||||
if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var total int
|
||||
|
||||
return query.Row(&total) == nil && total == 0
|
||||
}
|
||||
|
||||
// TruncateCollection deletes all records associated with the provided collection.
|
||||
//
|
||||
// The truncate operation is executed in a single transaction,
|
||||
@@ -177,7 +220,7 @@ func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds
|
||||
// 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 {
|
||||
|
||||
+110
-29
@@ -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++
|
||||
}
|
||||
|
||||
@@ -208,34 +208,6 @@ func TestFindCachedCollectionByNameOrId(t *testing.T) {
|
||||
run(false)
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
|
||||
result := app.IsCollectionNameUnique(s.name, s.excludeId)
|
||||
if result != s.expected {
|
||||
t.Errorf("Expected %v, got %v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -288,6 +260,115 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCachedCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
totalQueries := 0
|
||||
app.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
run := func(withCache bool) {
|
||||
var expectedTotalQueries int
|
||||
|
||||
if withCache {
|
||||
err := app.ReloadCachedCollections()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
app.Store().Reset(nil)
|
||||
expectedTotalQueries = 1
|
||||
}
|
||||
|
||||
totalQueries = 0
|
||||
|
||||
result, err := app.FindCachedCollectionReferences(
|
||||
collection,
|
||||
collection.Id,
|
||||
// test whether "nonempty" exclude ids condition will be skipped
|
||||
"",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_one_no_cascade_required",
|
||||
"rel_one_cascade",
|
||||
"rel_one_unique",
|
||||
"rel_many_no_cascade",
|
||||
"rel_many_no_cascade_required",
|
||||
"rel_many_cascade",
|
||||
"rel_many_unique",
|
||||
}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo4" {
|
||||
t.Fatalf("Expected collection demo4, got %s", col.Name)
|
||||
}
|
||||
if len(fields) != len(expectedFields) {
|
||||
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
|
||||
}
|
||||
for i, f := range fields {
|
||||
if !slices.Contains(expectedFields, f.GetName()) {
|
||||
t.Fatalf("[%d] Didn't expect field %v", i, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalQueries != expectedTotalQueries {
|
||||
t.Fatalf("Expected %d totalQueries, got %d", expectedTotalQueries, totalQueries)
|
||||
}
|
||||
}
|
||||
|
||||
run(true)
|
||||
|
||||
run(false)
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.name), func(t *testing.T) {
|
||||
result := app.IsCollectionNameUnique(s.name, s.excludeId)
|
||||
if result != s.expected {
|
||||
t.Errorf("Expected %v, got %v", s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionTruncate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -306,11 +306,14 @@ func dropCollectionIndexes(app App, collection *Collection) error {
|
||||
for _, raw := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
continue
|
||||
// note: don't check IsValid because the index table name may not be populated
|
||||
// (https://github.com/pocketbase/pocketbase/issues/7689)
|
||||
if parsed.IndexName == "" {
|
||||
return fmt.Errorf("failed to dop index - missing index name: %s", raw)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -349,9 +352,7 @@ func createCollectionIndexes(app App, collection *Collection) error {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
||||
).SetParams(map[string]any{
|
||||
"indexName": parsed.IndexName,
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,3 +294,49 @@ func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropIndexWithoutTableName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
properIndex := "CREATE INDEX `new_test_idx2` ON `new_test` (`test`)"
|
||||
indexWithoutTableName := "CREATE INDEX `new_test_idx2` ON `` (`test`)"
|
||||
|
||||
dummyCollection := core.NewBaseCollection("new_test")
|
||||
dummyCollection.Fields.Add(&core.TextField{Name: "test"})
|
||||
dummyCollection.Indexes = []string{properIndex}
|
||||
|
||||
err := app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave without table name but without hooks to avoid the normalizations
|
||||
dummyCollection.Indexes[0] = indexWithoutTableName
|
||||
err = app.UnsafeWithoutHooks().Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// resave should normalize the index
|
||||
err = app.Save(dummyCollection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dummyCollection, err = app.FindCollectionByNameOrId(dummyCollection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(dummyCollection.Indexes) != 1 || dummyCollection.Indexes[0] != properIndex {
|
||||
t.Fatalf("Expected exactly 1 index\n%s\ngot\n%v", properIndex, dummyCollection.Indexes)
|
||||
}
|
||||
}
|
||||
|
||||
+88
-48
@@ -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 {
|
||||
@@ -65,6 +69,10 @@ type optionsValidator interface {
|
||||
}
|
||||
|
||||
func (validator *collectionValidator) run() error {
|
||||
if validator.original.IsNew() {
|
||||
validator.new.updateGeneratedIdIfExists(validator.app)
|
||||
}
|
||||
|
||||
// generate fields from the query (overwriting any explicit user defined fields)
|
||||
if validator.new.IsView() {
|
||||
validator.new.Fields, _ = validator.app.CreateViewFields(validator.new.ViewQuery)
|
||||
@@ -79,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)),
|
||||
),
|
||||
@@ -264,7 +272,7 @@ func (validator *collectionValidator) checkFieldDuplicates(value any) error {
|
||||
strconv.Itoa(i): validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_duplicated_field_name",
|
||||
fmt.Sprintf("Duplicated or invalid field name %q", field.GetName()),
|
||||
"Duplicated or invalid field name {{.fieldName}}",
|
||||
).SetParams(map[string]any{
|
||||
"fieldName": field.GetName(),
|
||||
}),
|
||||
@@ -306,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
|
||||
@@ -416,6 +428,10 @@ func (validator *collectionValidator) ensureNoSystemFieldsChange(value any) erro
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
if validator.original.IsNew() {
|
||||
return nil // not an update
|
||||
}
|
||||
|
||||
for _, oldField := range validator.original.Fields {
|
||||
if !oldField.GetSystem() {
|
||||
continue
|
||||
@@ -444,12 +460,12 @@ func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
|
||||
for _, name := range names {
|
||||
field := cv.new.Fields.GetByName(name)
|
||||
if field == nil {
|
||||
return validation.NewError("validation_missing_field", fmt.Sprintf("Invalid or missing field %q", name)).
|
||||
return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) {
|
||||
return validation.NewError("validation_missing_unique_constraint", fmt.Sprintf("The field %q doesn't have a UNIQUE constraint.", name)).
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
|
||||
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
|
||||
SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
}
|
||||
@@ -477,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())
|
||||
@@ -516,7 +532,8 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
)
|
||||
}
|
||||
|
||||
indexNames := make(map[string]struct{}, len(indexes))
|
||||
duplicatedNames := make(map[string]struct{}, len(indexes))
|
||||
duplicatedDefinitions := make(map[string]struct{}, len(indexes))
|
||||
|
||||
for i, rawIndex := range indexes {
|
||||
parsed := dbutils.ParseIndex(rawIndex)
|
||||
@@ -533,19 +550,19 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
_, isDuplicated := indexNames[strings.ToLower(parsed.IndexName)]
|
||||
if isDuplicated {
|
||||
if _, isDuplicated := duplicatedNames[strings.ToLower(parsed.IndexName)]; isDuplicated {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_duplicated_index_name",
|
||||
"The index name must be unique.",
|
||||
"The index name already exists.",
|
||||
),
|
||||
}
|
||||
}
|
||||
duplicatedNames[strings.ToLower(parsed.IndexName)] = struct{}{}
|
||||
|
||||
// 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})).
|
||||
@@ -557,10 +574,25 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_existing_index_name",
|
||||
"The index name is already used in "+usedTblName+" collection.",
|
||||
"The index name is already used in {{.usedTableName}} collection.",
|
||||
).SetParams(map[string]any{"usedTableName": usedTblName}),
|
||||
}
|
||||
}
|
||||
|
||||
// reset non-important identifiers
|
||||
parsed.SchemaName = "validator"
|
||||
parsed.IndexName = "validator"
|
||||
parsedDef := parsed.Build()
|
||||
|
||||
if _, isDuplicated := duplicatedDefinitions[parsedDef]; isDuplicated {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_duplicated_index_definition",
|
||||
"The index definition already exists.",
|
||||
),
|
||||
}
|
||||
}
|
||||
duplicatedDefinitions[parsedDef] = struct{}{}
|
||||
|
||||
// note: we don't check the index table name because it is always
|
||||
// overwritten by the SyncRecordTableSchema to allow
|
||||
@@ -573,15 +605,24 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
|
||||
indexNames[strings.ToLower(parsed.IndexName)] = struct{}{}
|
||||
}
|
||||
|
||||
// ensure that indexes on system fields are not deleted or changed
|
||||
// ensure that unique indexes on system fields are not changed or removed
|
||||
if !cv.original.IsNew() {
|
||||
OLD_INDEXES_LOOP:
|
||||
for _, oldIndex := range cv.original.Indexes {
|
||||
oldParsed := dbutils.ParseIndex(oldIndex)
|
||||
if !oldParsed.Unique {
|
||||
continue
|
||||
}
|
||||
|
||||
// reset collate and sort since they are not important for the unique constraint
|
||||
for i := range oldParsed.Columns {
|
||||
oldParsed.Columns[i].Collate = ""
|
||||
oldParsed.Columns[i].Sort = ""
|
||||
}
|
||||
|
||||
oldParsedStr := oldParsed.Build()
|
||||
|
||||
for _, column := range oldParsed.Columns {
|
||||
for _, f := range cv.original.Fields {
|
||||
@@ -589,36 +630,35 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
continue
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
for i, newIndex := range cv.new.Indexes {
|
||||
var hasMatch bool
|
||||
for _, newIndex := range cv.new.Indexes {
|
||||
newParsed := dbutils.ParseIndex(newIndex)
|
||||
if !strings.EqualFold(newParsed.IndexName, oldParsed.IndexName) {
|
||||
continue
|
||||
|
||||
// exclude the non-important identifiers from the check
|
||||
newParsed.SchemaName = oldParsed.SchemaName
|
||||
newParsed.IndexName = oldParsed.IndexName
|
||||
newParsed.TableName = oldParsed.TableName
|
||||
|
||||
// exclude partial constraints
|
||||
newParsed.Where = oldParsed.Where
|
||||
|
||||
// reset collate and sort
|
||||
for i := range newParsed.Columns {
|
||||
newParsed.Columns[i].Collate = ""
|
||||
newParsed.Columns[i].Sort = ""
|
||||
}
|
||||
|
||||
// normalize table names of both indexes
|
||||
oldParsed.TableName = "validator"
|
||||
newParsed.TableName = "validator"
|
||||
|
||||
if oldParsed.Build() != newParsed.Build() {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_system_index_change",
|
||||
"Indexes on system fields cannot change.",
|
||||
),
|
||||
}
|
||||
if oldParsedStr == newParsed.Build() {
|
||||
hasMatch = true
|
||||
break
|
||||
}
|
||||
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if !hasMatch {
|
||||
return validation.NewError(
|
||||
"validation_missing_system_index",
|
||||
fmt.Sprintf("Missing system index %q.", oldParsed.IndexName),
|
||||
).SetParams(map[string]any{"name": oldParsed.IndexName})
|
||||
"validation_invalid_unique_system_field_index",
|
||||
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
|
||||
).SetParams(map[string]any{"fieldName": f.GetName()})
|
||||
}
|
||||
|
||||
continue OLD_INDEXES_LOOP
|
||||
@@ -630,15 +670,15 @@ func (cv *collectionValidator) checkIndexes(value any) error {
|
||||
// check for required indexes
|
||||
//
|
||||
// note: this is in case the indexes were removed manually when creating/importing new auth collections
|
||||
// and technically is not necessary since on app.Save the missing index will be reinserted by the system collection hook
|
||||
// and technically it is not necessary because on app.Save() the missing indexes will be reinserted by the system collection hook
|
||||
if cv.new.IsAuth() {
|
||||
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
|
||||
for _, name := range requiredNames {
|
||||
if !dbutils.HasSingleColumnUniqueIndex(name, indexes) {
|
||||
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
|
||||
return validation.NewError(
|
||||
"validation_missing_required_unique_index",
|
||||
`Missing required unique index for field "`+name+`".`,
|
||||
)
|
||||
`Missing required unique index for field "{{.fieldName}}".`,
|
||||
).SetParams(map[string]any{"fieldName": name})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user