mirror of
https://github.com/pocketbase/pocketbase.git
synced 2026-09-20 13:30:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0afc380a11 | ||
|
|
3551dea44a | ||
|
|
eff09852a4 | ||
|
|
90c313cf09 | ||
|
|
5574fe39ce | ||
|
|
6695aba758 | ||
|
|
1eeacf0204 | ||
|
|
4a1736a785 | ||
|
|
186d2ed328 | ||
|
|
35d7b5f056 | ||
|
|
bb410e7e0d | ||
|
|
9babca5f77 | ||
|
|
b845d3dbea | ||
|
|
39d24ba897 | ||
|
|
631957fa32 | ||
|
|
f414e70ffa | ||
|
|
d084800c45 | ||
|
|
f1a6c19309 | ||
|
|
53ee5212bc | ||
|
|
548fce20b5 | ||
|
|
1014c92d86 | ||
|
|
88c56cd539 | ||
|
|
a8b363ed76 | ||
|
|
6132fb4a03 | ||
|
|
20fba0f686 | ||
|
|
4f46222de9 | ||
|
|
4fba93e834 | ||
|
|
5a715cc60a | ||
|
|
f2ed186540 | ||
|
|
4937acb3e2 | ||
|
|
4743c1ce72 | ||
|
|
a11abef84b | ||
|
|
6aaf98215d | ||
|
|
2662d875b9 | ||
|
|
959c6b6d6c | ||
|
|
d4a2f05075 | ||
|
|
4c14c6cccf | ||
|
|
aaa6e971a3 | ||
|
|
27b6d0c505 | ||
|
|
a46815ed69 | ||
|
|
a5fdbeae79 | ||
|
|
8599754e45 | ||
|
|
71141dde69 | ||
|
|
388f61aed6 | ||
|
|
c32f272123 | ||
|
|
81ef6f1127 | ||
|
|
8f8a7c3268 | ||
|
|
1b89aabf14 | ||
|
|
5f1b2fda74 | ||
|
|
9f1c1c2e33 | ||
|
|
7ef118581b | ||
|
|
b7447f3e27 | ||
|
|
368af1f0fc | ||
|
|
41aa9b189c | ||
|
|
ed9cc2f33c | ||
|
|
bada2338f7 | ||
|
|
722a74994f | ||
|
|
442b286b1d | ||
|
|
5105612a45 | ||
|
|
b9029010d9 | ||
|
|
03a3f9876e | ||
|
|
49adba6947 | ||
|
|
ef965aafbb | ||
|
|
fa8e3d83b5 | ||
|
|
8402938191 | ||
|
|
8a0eed22fa | ||
|
|
3b6fcf265a | ||
|
|
fb78a39161 | ||
|
|
9436efb7fd | ||
|
|
05556e7cbc | ||
|
|
2862119c1f | ||
|
|
eaf121ead7 | ||
|
|
aabe820e35 | ||
|
|
80d65a198b | ||
|
|
3013e0299a | ||
|
|
6fd2e7ab0f | ||
|
|
a44a73a17c | ||
|
|
bf30af393e | ||
|
|
f0410a7625 | ||
|
|
ba56623245 | ||
|
|
702b4aa1c2 | ||
|
|
3f7db19fdd | ||
|
|
9855397a22 | ||
|
|
d9b219d64f | ||
|
|
c642a860ca | ||
|
|
b2b792b763 | ||
|
|
fc18e69183 | ||
|
|
fa65038fc1 | ||
|
|
9419d1928a | ||
|
|
c9bc2f07aa | ||
|
|
28fc186f5c | ||
|
|
cdb539dcc8 | ||
|
|
af7c6d8d9b | ||
|
|
cd2fc536ca | ||
|
|
2a28f6ff33 | ||
|
|
036c0da05f | ||
|
|
d795a6671b | ||
|
|
8cff94f27c | ||
|
|
931f6bc0cb | ||
|
|
2e3ae1b60a | ||
|
|
6155e6426a | ||
|
|
0f95a11fc1 | ||
|
|
eb695cc6d3 | ||
|
|
28d15e86eb | ||
|
|
b033109654 | ||
|
|
d0352aa3f9 | ||
|
|
3b7d0e84f6 | ||
|
|
592b7e85c6 | ||
|
|
3792d44c35 | ||
|
|
a021fcaa75 | ||
|
|
d123e19e61 | ||
|
|
982f876a93 | ||
|
|
1fcc2d8683 | ||
|
|
4f2492290e | ||
|
|
8f625daa2f | ||
|
|
0599955676 | ||
|
|
97a8409a65 | ||
|
|
422eb30797 | ||
|
|
c4116e3a7d | ||
|
|
64cee264f0 | ||
|
|
0ae9f24a81 | ||
|
|
6d942c7d30 | ||
|
|
705d7f48e7 | ||
|
|
9f67c5d563 | ||
|
|
1ac7330e0b | ||
|
|
e73b3a32d2 | ||
|
|
461886f64e | ||
|
|
370862fa2e | ||
|
|
6b3780c630 | ||
|
|
c807f66c59 | ||
|
|
8d97eb0769 | ||
|
|
5f5f9ca426 | ||
|
|
4e91be6d74 | ||
|
|
1208edec92 | ||
|
|
5555e63116 | ||
|
|
d6569b445c | ||
|
|
0b4f3b2adf | ||
|
|
8bd968ed06 | ||
|
|
5c961f8537 | ||
|
|
bf5eba0384 | ||
|
|
c213d9313e | ||
|
|
b31cf984a5 | ||
|
|
8671debc35 | ||
|
|
b0f027d27a | ||
|
|
98c8c98603 | ||
|
|
97345f0317 | ||
|
|
b29e404f22 | ||
|
|
d8ec36fa4c | ||
|
|
fb2eafe860 | ||
|
|
b9f391cf85 | ||
|
|
646f90ef43 | ||
|
|
5b6b4599b7 | ||
|
|
35fc6d0734 | ||
|
|
506b759560 | ||
|
|
d86e20b7f2 | ||
|
|
4c473385b2 | ||
|
|
afbbc1d97c | ||
|
|
4d3ba270c0 | ||
|
|
1bf7f148b0 | ||
|
|
6e6c873cc6 | ||
|
|
16da7d9e1a | ||
|
|
f7df737c45 | ||
|
|
64eefb44e8 | ||
|
|
31317df21c | ||
|
|
865865fdeb | ||
|
|
5b2575b754 | ||
|
|
6327ac20da | ||
|
|
8cd1c8709c | ||
|
|
14a2fd6215 | ||
|
|
cdfc1f7b70 | ||
|
|
41dcd9b4d4 | ||
|
|
0fb859c321 | ||
|
|
f57d38f529 | ||
|
|
04024cb6b7 | ||
|
|
58a2d3cd09 | ||
|
|
4d27278c60 | ||
|
|
70f1647a4c | ||
|
|
5b94aced3a | ||
|
|
070a1cd6d9 | ||
|
|
716f508d66 | ||
|
|
7013174315 | ||
|
|
559aad36a3 | ||
|
|
6416328c3b | ||
|
|
d3713a9d7c | ||
|
|
aaab643629 | ||
|
|
b283ee2263 | ||
|
|
995733000f | ||
|
|
99bdb4e701 | ||
|
|
3b79535dc7 | ||
|
|
05cc3f9e6c | ||
|
|
3f2e38ca82 | ||
|
|
531a7abec9 | ||
|
|
821aae4a62 | ||
|
|
ff5535f4de | ||
|
|
985ab1e5b7 | ||
|
|
69a805d0d1 | ||
|
|
d240649497 | ||
|
|
9957919d9a | ||
|
|
890a0904cf | ||
|
|
cdd32512d5 | ||
|
|
5835193900 | ||
|
|
4abe199acc | ||
|
|
a170923637 | ||
|
|
f4f3724b7a | ||
|
|
ba7cf8bf8e | ||
|
|
500615c1ee | ||
|
|
8961232a44 | ||
|
|
907167e696 | ||
|
|
4e51e393a2 | ||
|
|
5ea784609f | ||
|
|
ea5ca009de | ||
|
|
d13802133a | ||
|
|
f3a40001a4 | ||
|
|
1ae570921b | ||
|
|
f889a3fcb3 | ||
|
|
34fed679fd | ||
|
|
b7a49efa88 | ||
|
|
01e8c0f9f7 | ||
|
|
1d67a35acf | ||
|
|
e2d8028d0a | ||
|
|
d8a1875f84 | ||
|
|
79617e6d99 | ||
|
|
e6f1b3dfe4 | ||
|
|
94253f0dd5 | ||
|
|
6cfaf343ac | ||
|
|
9c562294ff | ||
|
|
3c5409d607 | ||
|
|
8868fa9ae6 | ||
|
|
c0fa53a2ab | ||
|
|
007b6a04ff | ||
|
|
731383a915 | ||
|
|
866d38caf9 | ||
|
|
1f6ab24b34 | ||
|
|
01e33c07fe | ||
|
|
69983bff5e | ||
|
|
2567659696 | ||
|
|
3e487f7e9d | ||
|
|
ca1a395628 | ||
|
|
1a47c70ccf | ||
|
|
1f4bdfb867 | ||
|
|
eae16cc42c | ||
|
|
1527b5ea4f | ||
|
|
ba6e17b3be | ||
|
|
b8219af941 | ||
|
|
8865cc1431 | ||
|
|
20b6ce4b84 | ||
|
|
e2f806d8bb | ||
|
|
49e3f4ad93 | ||
|
|
6d672348e7 | ||
|
|
80d774a8ef | ||
|
|
5a5125383a | ||
|
|
7fa1ff53c9 | ||
|
|
0f4e27a11f | ||
|
|
9997223923 | ||
|
|
632ade795f | ||
|
|
91bd739b71 | ||
|
|
957064d70b | ||
|
|
609792a355 | ||
|
|
2f5cfcfe87 | ||
|
|
5732bc38e3 | ||
|
|
d69181cfef | ||
|
|
8908d03b8c | ||
|
|
ebf73f5602 | ||
|
|
8416f03bcf | ||
|
|
5d87385170 | ||
|
|
837134559f | ||
|
|
fadd12cd22 | ||
|
|
469769d270 | ||
|
|
e4b7303a5d | ||
|
|
e1fb5d26a5 | ||
|
|
4f396ca439 | ||
|
|
ff08fc0fa4 | ||
|
|
4b511475ff | ||
|
|
2550a9de54 | ||
|
|
0f5dad7ede | ||
|
|
d0b1c9d998 | ||
|
|
fd9e120434 | ||
|
|
92731ddd50 | ||
|
|
4b4aaf2112 | ||
|
|
6013d14bc6 | ||
|
|
f3bcd7d3df | ||
|
|
71f9be3cb0 | ||
|
|
f605521208 | ||
|
|
4927583790 | ||
|
|
6e80cb8136 | ||
|
|
bb0a2dd698 | ||
|
|
2608efb56c | ||
|
|
eb2aa1cfc6 | ||
|
|
e1528aedac | ||
|
|
22b0a2b586 | ||
|
|
0ca86a0c87 | ||
|
|
b2c8f394af | ||
|
|
56b2641469 | ||
|
|
f266621a0f | ||
|
|
ca136c5dc1 | ||
|
|
abfe18bcce | ||
|
|
f3fc7f78d7 | ||
|
|
26fd069d11 | ||
|
|
62bde9e1f3 | ||
|
|
b945cd2fdf | ||
|
|
89a0520f7d | ||
|
|
a88b3c5db3 | ||
|
|
a37d9cfb84 | ||
|
|
5b084bbbfd | ||
|
|
322508f6d1 | ||
|
|
78e70bd52b | ||
|
|
58401459bf | ||
|
|
f172754775 | ||
|
|
31670ab3e1 | ||
|
|
baacf4913b | ||
|
|
8a94ccea42 | ||
|
|
b2bab9573a | ||
|
|
e5b5c1f76f | ||
|
|
ccb1c42220 | ||
|
|
a394777264 | ||
|
|
7d10b3c502 | ||
|
|
64ffb308bb | ||
|
|
916c74c218 | ||
|
|
17974d534e | ||
|
|
bde7a86b30 | ||
|
|
f7f8f09336 | ||
|
|
2a6b891a9b | ||
|
|
b4cb35483b | ||
|
|
1606cfd6e2 | ||
|
|
08f97ef0bb | ||
|
|
0dc263a40c | ||
|
|
824031e1a4 | ||
|
|
311bc74b7e | ||
|
|
4f3d1682de | ||
|
|
18728732b9 | ||
|
|
bb4f27cfb5 | ||
|
|
d423acad3b | ||
|
|
ef73052546 | ||
|
|
c89c68a4dc | ||
|
|
02495554cf | ||
|
|
cdbe6d78d3 | ||
|
|
ff6904f1f8 | ||
|
|
bc0222dcb4 | ||
|
|
04826ba588 | ||
|
|
6ca1f5c431 | ||
|
|
2863763a27 | ||
|
|
9c0d952543 | ||
|
|
ed4f7c7358 | ||
|
|
49f1c869c0 | ||
|
|
5e6949062f | ||
|
|
dc063b20fa | ||
|
|
f42bbfd927 | ||
|
|
f0af24d78f | ||
|
|
26fd3d48df | ||
|
|
864bbe7e12 | ||
|
|
1e995552c8 | ||
|
|
6baae97b5d | ||
|
|
bcfbbc53f8 | ||
|
|
8a916cd636 | ||
|
|
75f58a28ac | ||
|
|
e87ef431c5 | ||
|
|
b2ac538580 | ||
|
|
53b20ec104 | ||
|
|
c8ef3c4050 | ||
|
|
9113d30103 | ||
|
|
fef6e584b7 | ||
|
|
67fa47b1bb | ||
|
|
5f21c4119f | ||
|
|
734f35c504 | ||
|
|
038ae8f803 | ||
|
|
2236288e57 | ||
|
|
8f10c66160 | ||
|
|
5960dc5f2d | ||
|
|
cbf1215bb1 | ||
|
|
1b633720be | ||
|
|
adb5d6e998 | ||
|
|
3841946b61 | ||
|
|
369e2703c2 | ||
|
|
4a45ad91fa | ||
|
|
265dac45ce | ||
|
|
f152787578 | ||
|
|
640623f4fd | ||
|
|
1aff89f377 | ||
|
|
7d6b12a4ef | ||
|
|
7a3223e415 | ||
|
|
bd18688f35 | ||
|
|
f90da96820 | ||
|
|
6c8f2d2cd6 | ||
|
|
5e84305922 | ||
|
|
b3421861e6 | ||
|
|
872492ad22 | ||
|
|
5c14c7cf5e | ||
|
|
b59f0f418e | ||
|
|
06d3e27e03 | ||
|
|
b1093baef7 | ||
|
|
b3f09ff045 | ||
|
|
b33ad36f64 | ||
|
|
9254ce46eb | ||
|
|
cc8c855306 | ||
|
|
b74994b906 | ||
|
|
f652dc71bb | ||
|
|
6d2677a5e3 | ||
|
|
0c5305c174 | ||
|
|
5398576f4f | ||
|
|
3d1f570b38 | ||
|
|
fa057502f1 | ||
|
|
bb4a5cfe83 | ||
|
|
ac1fd74942 | ||
|
|
db660ac780 | ||
|
|
cdeb9a94ed | ||
|
|
6da94aef8d | ||
|
|
0a4fdc17a5 | ||
|
|
1bbba7a0ae | ||
|
|
fcc4e305e0 | ||
|
|
854796a8dd | ||
|
|
e6a41773ca | ||
|
|
f4a6d8af49 | ||
|
|
1563855251 | ||
|
|
1330e2e1e7 | ||
|
|
34fe55686d | ||
|
|
b0aa387235 | ||
|
|
c3f7aeb856 | ||
|
|
54a6ae6710 | ||
|
|
8dfc90985b | ||
|
|
99ea916c14 | ||
|
|
70151a3c19 | ||
|
|
543fb350ec | ||
|
|
ea4e3128ca | ||
|
|
ae8cbc8f45 | ||
|
|
cb156e1903 | ||
|
|
edcb6950e5 | ||
|
|
085fb1601e | ||
|
|
132a8c0aab | ||
|
|
4f3ca6fe2b | ||
|
|
13c0572fe1 | ||
|
|
fda4b67dbc | ||
|
|
aefbccbfea | ||
|
|
d1336da339 | ||
|
|
f453cefc0b | ||
|
|
b6bc09fee1 | ||
|
|
437843084b | ||
|
|
1e4c665b53 | ||
|
|
ac52befb5b | ||
|
|
50d7df45eb | ||
|
|
7e0a4e61b4 | ||
|
|
689ad644c1 | ||
|
|
f660707712 | ||
|
|
06016722d1 | ||
|
|
832d7f360c | ||
|
|
939653ecc0 | ||
|
|
610a948dcc | ||
|
|
b2284b5f4b | ||
|
|
d9e1a759a1 | ||
|
|
624b443f98 | ||
|
|
71a70bac9d | ||
|
|
0110869c89 | ||
|
|
7d4017225c | ||
|
|
94a1cc07d5 | ||
|
|
1720c82570 | ||
|
|
81bd1a1732 | ||
|
|
f421da4b9b | ||
|
|
3eaa3ca1b5 | ||
|
|
2d1ad16b4f | ||
|
|
6179864828 | ||
|
|
64d7ab22f3 | ||
|
|
4962dc618b | ||
|
|
8e2246113a | ||
|
|
b9993aaa73 | ||
|
|
f77fb0cc1c | ||
|
|
460cc35bb6 | ||
|
|
f0bcffec8b | ||
|
|
2b465b0646 | ||
|
|
fdccdcebad | ||
|
|
a38bd5bedc | ||
|
|
6fe04bd280 | ||
|
|
d0a68da7e7 | ||
|
|
ede67dbc20 | ||
|
|
3d3fe5c614 | ||
|
|
7bb33d4453 | ||
|
|
0ad4dbc65a | ||
|
|
c3844250e8 | ||
|
|
c293994d2b | ||
|
|
a557aa35f5 | ||
|
|
736e9673ad | ||
|
|
13d96e793b | ||
|
|
5e37c90dde | ||
|
|
7bcd00a87e | ||
|
|
ebfbb55f91 | ||
|
|
d77479131a | ||
|
|
8ef00efe84 | ||
|
|
a4101f7670 | ||
|
|
08b4fc20a9 | ||
|
|
9ec01d74d8 | ||
|
|
320f990f84 | ||
|
|
7297f55ca4 | ||
|
|
2cb642bbf7 | ||
|
|
ecdf9c26cd | ||
|
|
a672ab959f | ||
|
|
1571ebe4eb | ||
|
|
b8bb5e8d72 | ||
|
|
af77554250 | ||
|
|
3b68782cfb | ||
|
|
1679c88e6d | ||
|
|
91bbbc4bdb | ||
|
|
4ab9c6f87f | ||
|
|
68157a3a65 | ||
|
|
e6cf4ad2ef | ||
|
|
edd2eaae88 | ||
|
|
2e8e835a68 | ||
|
|
9cba6ac386 | ||
|
|
8388e36f28 | ||
|
|
051b3702b0 | ||
|
|
39accdba58 | ||
|
|
32de0aa40a | ||
|
|
9bfcdd086a | ||
|
|
1d20124467 | ||
|
|
435eca6f35 | ||
|
|
0a61db6efd | ||
|
|
7ab8405946 | ||
|
|
6fa3e99be2 | ||
|
|
3160fb2d99 | ||
|
|
1cbf16b3bf | ||
|
|
dad289b90d | ||
|
|
c795ecd21e | ||
|
|
21607f0f66 | ||
|
|
fc311a8d28 | ||
|
|
93606c6647 | ||
|
|
1adcfcc03b | ||
|
|
ed4304dc30 | ||
|
|
c0a6a21b9e | ||
|
|
a7bb599cd0 | ||
|
|
bd94940eef | ||
|
|
caf343ef9c | ||
|
|
28ba4655c1 | ||
|
|
bd95a5b74c | ||
|
|
745b230097 | ||
|
|
ec303a60ed | ||
|
|
e99b0627d6 | ||
|
|
d1bcaa65b1 | ||
|
|
2d60ad170e | ||
|
|
0ba963a5d7 | ||
|
|
b77b6d1a18 | ||
|
|
779b23d919 | ||
|
|
87a6f1bebb | ||
|
|
aa5b5bb83e | ||
|
|
af193d440d | ||
|
|
7f06816008 | ||
|
|
a5b27cce5c | ||
|
|
ebd6891471 | ||
|
|
3cf3e04866 | ||
|
|
ff5508cb79 | ||
|
|
e4a90f6605 | ||
|
|
f07f7a1e35 | ||
|
|
acbce42bff | ||
|
|
b33bebd4cd | ||
|
|
881b625177 | ||
|
|
4c2dcac61a | ||
|
|
dcb00a3917 | ||
|
|
ddca49ba16 | ||
|
|
0fb92720f8 | ||
|
|
3122223a71 | ||
|
|
7de346b532 | ||
|
|
729f9f142e | ||
|
|
45b73e3dfb | ||
|
|
dbbc1e25ca | ||
|
|
d3711b0503 | ||
|
|
9d8df8d05d | ||
|
|
97f29e4305 | ||
|
|
732044f795 | ||
|
|
9a1354ae62 | ||
|
|
fcfcaa0628 | ||
|
|
d5314b028b | ||
|
|
e3876c0e13 | ||
|
|
b1307c9041 | ||
|
|
dcdf43f0fc | ||
|
|
f6a616b7e8 | ||
|
|
3be5875ea9 | ||
|
|
833a84b3d7 | ||
|
|
94680c41f7 | ||
|
|
c72c951b24 | ||
|
|
af71b63f23 | ||
|
|
e40cf46b33 | ||
|
|
e9969ed6d1 | ||
|
|
511259ed10 | ||
|
|
6b16b7856b | ||
|
|
5b330ab5b4 | ||
|
|
7dcfa65146 | ||
|
|
a6bb1bf096 | ||
|
|
728427cecf | ||
|
|
231ddc9791 | ||
|
|
5bf2f692d8 | ||
|
|
ce28a9af78 | ||
|
|
db20e38cda | ||
|
|
651b439096 | ||
|
|
286046e15a | ||
|
|
7a41ff0127 | ||
|
|
4440b5f817 | ||
|
|
d7745ba702 | ||
|
|
007ef1e152 | ||
|
|
a291cb5ca7 | ||
|
|
86049ed048 | ||
|
|
6457fc794f | ||
|
|
0eecbd8289 | ||
|
|
6521decfa8 | ||
|
|
c370d84074 | ||
|
|
4f5aa6ffda | ||
|
|
8921712821 | ||
|
|
1c63ae1324 | ||
|
|
cbaca91581 | ||
|
|
5551f8f5aa | ||
|
|
a56a04ed0e | ||
|
|
47bb0bc2d8 | ||
|
|
f49e90bd0b | ||
|
|
3820f3d7d7 | ||
|
|
faaf19d3bb | ||
|
|
6d0303deaf | ||
|
|
d36a044dc7 | ||
|
|
1cc67ef7e3 | ||
|
|
1e2d1045b8 | ||
|
|
df5a291d20 | ||
|
|
04e0ad9b21 | ||
|
|
24ab233376 | ||
|
|
a42ab6a205 | ||
|
|
472671fee1 | ||
|
|
6bde84131c | ||
|
|
e8b4a7eb26 | ||
|
|
3b0f60fe15 | ||
|
|
d3314e1e23 | ||
|
|
60eee96034 | ||
|
|
c6d5992442 | ||
|
|
a7f3805f87 | ||
|
|
92f21e3355 | ||
|
|
4c57968d89 | ||
|
|
b84ebabf4a | ||
|
|
90abe1612e | ||
|
|
dfabfa779e | ||
|
|
28c9f02cb4 | ||
|
|
67e1974228 | ||
|
|
0dcd332962 | ||
|
|
f70b457507 | ||
|
|
c8fd831115 | ||
|
|
beca0a044e | ||
|
|
9fa56b020c | ||
|
|
429929dd0c | ||
|
|
ecf7d657c0 | ||
|
|
4296148c87 | ||
|
|
bfb38ab51e | ||
|
|
1967dcfeba | ||
|
|
5c95e9b109 | ||
|
|
39c3a95a08 | ||
|
|
74306c8092 | ||
|
|
a5b3cc0f34 | ||
|
|
4944884683 | ||
|
|
c0a7d0f6c0 | ||
|
|
841a4b6913 | ||
|
|
0478f84867 | ||
|
|
0b5e189563 | ||
|
|
7ce21cc4d9 | ||
|
|
e61d48fe7b | ||
|
|
7acd90498c | ||
|
|
cae3315e46 | ||
|
|
f2a011d63f | ||
|
|
b31c2ceffa | ||
|
|
a2a170ad82 | ||
|
|
92dcee7250 | ||
|
|
bd2521b14b | ||
|
|
0c63e0e219 | ||
|
|
d407ca2163 | ||
|
|
5c57be8469 | ||
|
|
d8f9b52011 | ||
|
|
faa0a9f7dc | ||
|
|
4d94673839 | ||
|
|
4b0e614c68 | ||
|
|
5c95a43042 | ||
|
|
025c9313e1 | ||
|
|
0b023b2c02 | ||
|
|
8240addfab | ||
|
|
e1cfe7fc4a | ||
|
|
01f4765c09 | ||
|
|
818857dea2 | ||
|
|
3358d8476b | ||
|
|
060ed8013e | ||
|
|
fdf4f6d3bd | ||
|
|
d69e72deaf | ||
|
|
0420a00c18 | ||
|
|
b39d607c8a | ||
|
|
abea28a1a9 | ||
|
|
a7d5a0640c | ||
|
|
c937c06688 | ||
|
|
d11cec2226 | ||
|
|
c179b4c473 | ||
|
|
00b04db5cf | ||
|
|
1b8776926e | ||
|
|
5eb54c7a3d | ||
|
|
6127350e91 | ||
|
|
177230a765 | ||
|
|
8cb0d980ad | ||
|
|
d44c2bb1a9 | ||
|
|
3423433d01 | ||
|
|
646b6a925b | ||
|
|
5ddf9cd443 | ||
|
|
0351f3a1ad | ||
|
|
53c735d00b | ||
|
|
25769e971a | ||
|
|
aba6279feb | ||
|
|
ac4a961a10 | ||
|
|
8317ae2e6b | ||
|
|
b347c8d80c | ||
|
|
af5f808144 | ||
|
|
a77b62e5bd | ||
|
|
c77467a6a2 | ||
|
|
35e433f26b | ||
|
|
59f23d3d23 | ||
|
|
6a69a035a7 | ||
|
|
b8373b23a4 | ||
|
|
25c1db40ac | ||
|
|
7b48e9d1c1 | ||
|
|
b537085bca | ||
|
|
3e5b021dd8 | ||
|
|
151dbafc86 | ||
|
|
dc72d5adee | ||
|
|
c826514eca | ||
|
|
b7407edcae | ||
|
|
1420d717e3 | ||
|
|
f5f8c35a17 | ||
|
|
733d7dacdb | ||
|
|
cd45854792 | ||
|
|
ba7000125b | ||
|
|
64c3e3b3c5 | ||
|
|
9f76ad234c | ||
|
|
8ca439e5f7 | ||
|
|
f31a3b133c | ||
|
|
850fe353da | ||
|
|
16a6b32a76 | ||
|
|
a1487c3235 | ||
|
|
5255515e26 | ||
|
|
0b90ce43c3 | ||
|
|
899b8217e0 | ||
|
|
69bf9779d9 | ||
|
|
48d6803d17 | ||
|
|
216efb95a8 | ||
|
|
29a264e132 | ||
|
|
976a9e2f27 | ||
|
|
005e77151d | ||
|
|
f12467f2b3 | ||
|
|
fe03dd0789 | ||
|
|
ff1f99436a | ||
|
|
3ea02c945d | ||
|
|
4c903684d8 | ||
|
|
d0239f25ed | ||
|
|
05adb8c018 | ||
|
|
c901c9ab7d | ||
|
|
c9fba9972a | ||
|
|
2c40811fac | ||
|
|
b81112f82e | ||
|
|
0acf8198a4 | ||
|
|
5ece360808 | ||
|
|
3a5d3d521f | ||
|
|
f024de3cc4 | ||
|
|
a4abe9e2cb | ||
|
|
5678339af0 | ||
|
|
e5a22b8bd8 | ||
|
|
be0ee7f66c | ||
|
|
78a9f7429c | ||
|
|
d8b4daf47f | ||
|
|
7a2360d785 | ||
|
|
67ecebe935 | ||
|
|
5cbdbf6c10 | ||
|
|
0710214701 | ||
|
|
9736a45e80 | ||
|
|
923fc26a31 | ||
|
|
a79f3a7c56 | ||
|
|
8430944650 | ||
|
|
9b54fd3516 | ||
|
|
226352f884 | ||
|
|
2a682a10b2 | ||
|
|
b67549209a | ||
|
|
1126a0e16f | ||
|
|
17472cb40a | ||
|
|
981de64c7f | ||
|
|
1b45e23c81 | ||
|
|
29621303df | ||
|
|
a0ec5707d1 | ||
|
|
695c20a969 | ||
|
|
95bb2eb871 | ||
|
|
971916c20d | ||
|
|
44f5172db7 | ||
|
|
5fd103481c | ||
|
|
82abd7e0b0 | ||
|
|
361cfb56b7 | ||
|
|
4d16d0e16e | ||
|
|
254e691e92 | ||
|
|
e735d9d21b | ||
|
|
31b24ecb86 | ||
|
|
3eb3d0bbdf | ||
|
|
f180af7af7 | ||
|
|
d046811df7 | ||
|
|
4768e07c0b | ||
|
|
0547202382 | ||
|
|
684f91f7e5 | ||
|
|
bce4094134 | ||
|
|
6a60bc1df3 | ||
|
|
19b7a7a74f | ||
|
|
8de0e61cf4 | ||
|
|
5344ec83fa | ||
|
|
65aa114103 | ||
|
|
8728161288 | ||
|
|
2420b2804a | ||
|
|
2791628d96 | ||
|
|
01f0af7af6 | ||
|
|
e8d61e7b45 | ||
|
|
a5ac83c7b0 | ||
|
|
a67c14c368 | ||
|
|
19faa0d8e7 | ||
|
|
56a77fd5ff | ||
|
|
07727dbde6 | ||
|
|
a3d26a73c3 | ||
|
|
b328827705 | ||
|
|
f5e5fae773 | ||
|
|
41f01bab0d |
@@ -1,13 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: pocketbase
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: ['https://www.paypal.com/donate/?hosted_button_id=4DVXNL4B8WT98']
|
||||
@@ -9,19 +9,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: 20.11.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '>=1.20.0'
|
||||
go-version: '>=1.22.1'
|
||||
|
||||
# This step usually is not needed because the /ui/dist is pregenerated locally
|
||||
# but its here to ensure that each release embeds the latest admin ui artifacts.
|
||||
@@ -29,6 +29,13 @@ jobs:
|
||||
- name: Build Admin dashboard UI
|
||||
run: npm --prefix=./ui ci && npm --prefix=./ui run build
|
||||
|
||||
# Temporary disable as the types can have random generated identifiers making it non-deterministic.
|
||||
#
|
||||
# # Similar to the above, the jsvm types are pregenerated locally
|
||||
# # but its here to ensure that it wasn't forgotten to be executed.
|
||||
# - name: Generate jsvm types
|
||||
# run: go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
# The prebuilt golangci-lint doesn't support go 1.18+ yet
|
||||
# https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - name: Run linter
|
||||
@@ -42,6 +49,6 @@ jobs:
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+20
-4
@@ -7,7 +7,21 @@ before:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./examples/base
|
||||
# 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
|
||||
ldflags:
|
||||
- -s -w -X github.com/pocketbase/pocketbase.Version={{ .Version }}
|
||||
@@ -33,10 +47,12 @@ release:
|
||||
draft: true
|
||||
|
||||
archives:
|
||||
- format: zip
|
||||
- id: archive_noncgo
|
||||
builds: [build_noncgo]
|
||||
format: zip
|
||||
files:
|
||||
- LICENSE*
|
||||
- CHANGELOG*
|
||||
- LICENSE.md
|
||||
- CHANGELOG.md
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
+835
-1124
File diff suppressed because it is too large
Load Diff
+1384
File diff suppressed because it is too large
Load Diff
+22
-20
@@ -1,5 +1,4 @@
|
||||
Contributing to PocketBase
|
||||
======================================================================
|
||||
# Contributing to PocketBase
|
||||
|
||||
Thanks for taking the time to improve PocketBase!
|
||||
|
||||
@@ -9,26 +8,26 @@ This document describes how to prepare a PR for a change in the main repository.
|
||||
- [Making changes in the Go code](#making-changes-in-the-go-code)
|
||||
- [Making changes in the Admin UI](#making-changes-in-the-admin-ui)
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.18+ (for making changes in the Go code)
|
||||
- Node 16+ (for making changes in the Admin UI)
|
||||
- Go 1.21+ (for making changes in the Go code)
|
||||
- Node 18+ (for making changes in the Admin UI)
|
||||
|
||||
If you haven't already, you can fork the main repository and clone your fork so that you can work locally:
|
||||
|
||||
```
|
||||
git clone https://github.com/your_username/pocketbase.git
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> It is recommended to create a new branch from master for each of your bugfixes and features.
|
||||
> This is required if you are planning to submit multiple PRs in order to keep the changes separate for review until they eventually get merged.
|
||||
|
||||
|
||||
## Making changes in the Go code
|
||||
|
||||
PocketBase is distributed as a Go package, which means that in order to run the project you'll have to create a Go `main` program that imports the package.
|
||||
|
||||
The repository already includes such program, located in `/examples/base`, that is also used for the prebuilt executables.
|
||||
The repository already includes such program, located in `examples/base`, that is also used for the prebuilt executables.
|
||||
|
||||
So, let's assume that you already done some changes in the PocketBase Go code and you want now to run them:
|
||||
|
||||
@@ -41,20 +40,22 @@ This will start a web server on `http://localhost:8090` with the embedded prebui
|
||||
|
||||
- Add unit/integration tests for your changes (we are using the standard `testing` go package).
|
||||
To run the tests, you could execute (while in the root project directory):
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
```sh
|
||||
go test ./...
|
||||
|
||||
# or using the Makefile
|
||||
make test
|
||||
```
|
||||
|
||||
- Run the linter - **golangci** ([see how to install](https://golangci-lint.run/usage/install/#local-installation)):
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
```sh
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
# or using the Makefile
|
||||
make lint
|
||||
```
|
||||
|
||||
## Making changes in the Admin UI
|
||||
|
||||
@@ -65,14 +66,15 @@ To start the Admin UI:
|
||||
1. Navigate to the `ui` project directory
|
||||
2. Run `npm install` to install the node dependencies
|
||||
3. Start vite's dev server
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You could open the browser and access the running Admin UI at `http://localhost:3000`.
|
||||
|
||||
Since the Admin UI is just a client-side application, you need to have the PocketBase backend server also running in the background (either manually running the `examples/base/main.go` or download a prebuilt executable).
|
||||
|
||||
> [!NOTE]
|
||||
> By default, the Admin UI is expecting the backend server to be started at `http://localhost:8090`, but you could change that by creating a new `ui/.env.development.local` file with `PB_BACKEND_URL = YOUR_ADDRESS` variable inside it.
|
||||
|
||||
Every change you make in the Admin UI should be automatically reflected in the browser at `http://localhost:3000` without reloading the page.
|
||||
|
||||
@@ -4,6 +4,9 @@ lint:
|
||||
test:
|
||||
go test ./... -v --cover
|
||||
|
||||
jstypes:
|
||||
go run ./plugins/jsvm/internal/types/types.go
|
||||
|
||||
test-report:
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml" target="_blank" rel="noopener"><img src="https://github.com/pocketbase/pocketbase/actions/workflows/release.yaml/badge.svg" alt="build" /></a>
|
||||
<a href="https://github.com/pocketbase/pocketbase/releases" target="_blank" rel="noopener"><img src="https://img.shields.io/github/release/pocketbase/pocketbase.svg" alt="Latest releases" /></a>
|
||||
<a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/ganigeorgiev/fexpr?status.svg" alt="Go package documentation" /></a>
|
||||
<a href="https://pkg.go.dev/github.com/pocketbase/pocketbase" target="_blank" rel="noopener"><img src="https://godoc.org/github.com/pocketbase/pocketbase?status.svg" alt="Go package documentation" /></a>
|
||||
</p>
|
||||
|
||||
[PocketBase](https://pocketbase.io) is an open source Go backend, consisting of:
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
**For documentation and examples, please visit https://pocketbase.io/docs.**
|
||||
|
||||
> ⚠️ Please keep in mind that PocketBase is still under active development
|
||||
> [!WARNING]
|
||||
> Please keep in mind that PocketBase is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
|
||||
## API SDK clients
|
||||
|
||||
The easiest way to interact with the API is to use one of the official SDK clients:
|
||||
@@ -30,79 +30,90 @@ The easiest way to interact with the API is to use one of the official SDK clien
|
||||
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
|
||||
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
PocketBase could be [downloaded directly as a standalone app](https://github.com/pocketbase/pocketbase/releases) or it could be used as a Go framework/toolkit which allows you to build
|
||||
### Use as standalone app
|
||||
|
||||
You could download the prebuilt executable for your platform from the [Releases page](https://github.com/pocketbase/pocketbase/releases).
|
||||
Once downloaded, extract the archive and run `./pocketbase serve` in the extracted directory.
|
||||
|
||||
The prebuilt executables are based on the [`examples/base/main.go` file](https://github.com/pocketbase/pocketbase/blob/master/examples/base/main.go) and comes with the JS VM plugin enabled by default which allows to extend PocketBase with JavaScript (_for more details please refer to [Extend with JavaScript](https://pocketbase.io/docs/js-overview/)_).
|
||||
|
||||
### Use as a Go framework/toolkit
|
||||
|
||||
PocketBase is distributed as a regular Go library package which allows you to build
|
||||
your own custom app specific business logic and still have a single portable executable at the end.
|
||||
|
||||
### Installation
|
||||
Here is a minimal example:
|
||||
|
||||
```sh
|
||||
# go 1.18+
|
||||
go get github.com/pocketbase/pocketbase
|
||||
```
|
||||
> For Windows, you may have to use go 1.19+ due to an incorrect js mime type in the Windows Registry (see [issue#6](https://github.com/pocketbase/pocketbase/issues/6)).
|
||||
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
|
||||
### Example
|
||||
1. Create a new project directory with the following `main.go` file inside it:
|
||||
```go
|
||||
package main
|
||||
|
||||
```go
|
||||
package main
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// add new "GET /hello" route to the app router (echo)
|
||||
e.Router.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/hello",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "Hello world!")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.ActivityLogger(app),
|
||||
},
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// add new "GET /hello" route to the app router (echo)
|
||||
e.Router.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/hello",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "Hello world!")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.ActivityLogger(app),
|
||||
},
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Running and building
|
||||
2. To init the dependencies, run `go mod init myapp && go mod tidy`.
|
||||
|
||||
Running/building the application is the same as for any other Go program, aka. just `go run` and `go build`.
|
||||
3. To start the application, run `go run main.go serve`.
|
||||
|
||||
**PocketBase embeds SQLite, but doesn't require CGO.**
|
||||
4. To build a statically linked executable, you can run `CGO_ENABLED=0 go build` and then start the created executable with `./myapp serve`.
|
||||
|
||||
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.
|
||||
> [!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.18+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
0. [Install Go 1.21+](https://go.dev/doc/install) (_if you haven't already_)
|
||||
1. Clone/download the repo
|
||||
2. Navigate to `examples/base`
|
||||
3. Run `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
(_https://go.dev/doc/install/source#environment_)
|
||||
4. Start the created executable by running `./base serve`.
|
||||
|
||||
The supported build targets by the non-cgo driver at the moment are:
|
||||
Note that the supported build targets by the pure Go SQLite driver at the moment are:
|
||||
|
||||
```
|
||||
darwin amd64
|
||||
darwin arm64
|
||||
@@ -114,6 +125,7 @@ linux arm
|
||||
linux arm64
|
||||
linux ppc64le
|
||||
linux riscv64
|
||||
linux s390x
|
||||
windows amd64
|
||||
windows arm64
|
||||
```
|
||||
@@ -121,7 +133,8 @@ windows arm64
|
||||
### Testing
|
||||
|
||||
PocketBase comes with mixed bag of unit and integration tests.
|
||||
To run them, use the default `go test` command:
|
||||
To run them, use the standard `go test` command:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
```
|
||||
@@ -134,7 +147,6 @@ If you discover a security vulnerability within PocketBase, please send an e-mai
|
||||
|
||||
All reports will be promptly addressed, and you'll be credited accordingly.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
PocketBase is free and open source project licensed under the [MIT License](LICENSE.md).
|
||||
@@ -144,16 +156,11 @@ 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)
|
||||
- [Donate a small amount](https://pocketbase.io/support-us)
|
||||
|
||||
PRs for _small features_ (eg. adding new OAuth2 providers), bug and documentation fixes, etc. are more than welcome.
|
||||
PRs for new OAuth2 providers, bug fixes, code optimizations and documentation improvements are more than welcome.
|
||||
|
||||
But please refrain creating PRs for _big features_ without previously discussing the implementation details. Reviewing big PRs often requires a lot of time and tedious back-and-forth communication.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2)
|
||||
and I try to work on issues in a specific order and such PRs often come in out of nowhere and skew all initial planning.
|
||||
But please refrain creating PRs for _new features_ without previously discussing the implementation details.
|
||||
PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in specific order and such PRs often come in out of nowhere and skew all initial planning with tedious back-and-forth communication.
|
||||
|
||||
Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged.
|
||||
Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes).
|
||||
|
||||
_Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)),
|
||||
so all feature requests will be first aligned with what we need for Presentator v3._
|
||||
|
||||
+67
-63
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -33,18 +32,28 @@ type adminApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
|
||||
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin, finalizers ...func(token string) error) error {
|
||||
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
event := new(core.AdminAuthEvent)
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
event.Token = token
|
||||
|
||||
return api.app.OnAdminAuthRequest().Trigger(event, func(e *core.AdminAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(200, map[string]any{
|
||||
"token": e.Token,
|
||||
"admin": e.Admin,
|
||||
@@ -62,17 +71,11 @@ func (api *adminApi) authRefresh(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminBeforeAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.app.OnAdminAfterAuthRefreshRequest().Trigger(event, func(e *core.AdminAuthRefreshEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
@@ -95,17 +98,13 @@ func (api *adminApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
return api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event, func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return api.authResponse(e.HttpContext, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -129,30 +128,29 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
|
||||
return api.app.OnAdminBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Admin); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Admin); err != nil {
|
||||
api.app.Logger().Error("Failed to send admin password reset request.", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event, func(e *core.AdminRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against admins enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -173,17 +171,17 @@ func (api *adminApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -208,6 +206,10 @@ func (api *adminApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnAdminsListRequest().Trigger(event, func(e *core.AdminsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -228,6 +230,10 @@ func (api *adminApi) view(c echo.Context) error {
|
||||
event.Admin = admin
|
||||
|
||||
return api.app.OnAdminViewRequest().Trigger(event, func(e *core.AdminViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
}
|
||||
@@ -256,17 +262,17 @@ func (api *adminApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -302,17 +308,17 @@ func (api *adminApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
return api.app.OnAdminAfterUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Admin)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnAdminAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -331,19 +337,17 @@ func (api *adminApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Admin = admin
|
||||
|
||||
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
return api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
|
||||
return NewBadRequestError("Failed to delete admin.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnAdminAfterDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnAdminAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
@@ -15,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -89,6 +93,26 @@ func TestAdminAuthWithPassword(t *testing.T) {
|
||||
"OnAdminAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-with-password",
|
||||
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthWithPasswordRequest().Add(func(e *core.AdminAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthWithPasswordRequest": 1,
|
||||
"OnAdminAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -97,6 +121,8 @@ func TestAdminAuthWithPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -166,6 +192,8 @@ func TestAdminRequestPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -224,6 +252,29 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterConfirmPasswordResetRequest().Add(func(e *core.AdminConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnAdminAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -232,6 +283,8 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -278,6 +331,25 @@ func TestAdminRefresh(t *testing.T) {
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterAuthRefreshRequest().Add(func(e *core.AdminAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnAdminBeforeAuthRefreshRequest": 1,
|
||||
"OnAdminAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -286,6 +358,8 @@ func TestAdminRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -389,6 +463,8 @@ func TestAdminsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -444,6 +520,8 @@ func TestAdminView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -510,6 +588,27 @@ func TestAdminDelete(t *testing.T) {
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterDeleteRequest().Add(func(e *core.AdminDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnAdminBeforeDeleteRequest": 1,
|
||||
"OnAdminAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -518,6 +617,8 @@ func TestAdminDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized (while having at least 1 existing admin)",
|
||||
@@ -637,6 +738,33 @@ func TestAdminCreate(t *testing.T) {
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567890",
|
||||
"passwordConfirm":"1234567890",
|
||||
"avatar":3
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterCreateRequest().Add(func(e *core.AdminCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnAdminBeforeCreateRequest": 1,
|
||||
"OnAdminAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -645,6 +773,8 @@ func TestAdminCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -729,6 +859,7 @@ func TestAdminUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + valid data",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -759,6 +890,33 @@ func TestAdminUpdate(t *testing.T) {
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnAdminAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/admins/sbmbsdb40jyxf7h",
|
||||
Body: strings.NewReader(`{
|
||||
"email":"testnew@example.com",
|
||||
"password":"1234567891",
|
||||
"passwordConfirm":"1234567891",
|
||||
"avatar":5
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnAdminAfterUpdateRequest().Add(func(e *core.AdminUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnAdminBeforeUpdateRequest": 1,
|
||||
"OnAdminAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+50
-26
@@ -66,43 +66,67 @@ func NewUnauthorizedError(message string, data any) *ApiError {
|
||||
|
||||
// NewApiError creates and returns new normalized `ApiError` instance.
|
||||
func NewApiError(status int, message string, data any) *ApiError {
|
||||
message = inflector.Sentenize(message)
|
||||
|
||||
formattedData := map[string]any{}
|
||||
|
||||
if v, ok := data.(validation.Errors); ok {
|
||||
formattedData = resolveValidationErrors(v)
|
||||
}
|
||||
|
||||
return &ApiError{
|
||||
rawData: data,
|
||||
Data: formattedData,
|
||||
Data: safeErrorsData(data),
|
||||
Code: status,
|
||||
Message: strings.TrimSpace(message),
|
||||
Message: strings.TrimSpace(inflector.Sentenize(message)),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
|
||||
func safeErrorsData(data any) map[string]any {
|
||||
switch v := data.(type) {
|
||||
case validation.Errors:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]validation.Error:
|
||||
return resolveSafeErrorsData[validation.Error](v)
|
||||
case map[string]error:
|
||||
return resolveSafeErrorsData[error](v)
|
||||
case map[string]any:
|
||||
return resolveSafeErrorsData[any](v)
|
||||
default:
|
||||
return map[string]any{} // not nil to ensure that is json serialized as object
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSafeErrorsData[T any](data map[string]T) map[string]any {
|
||||
result := map[string]any{}
|
||||
|
||||
// extract from each validation error its error code and message.
|
||||
for name, err := range validationErrors {
|
||||
// check for nested errors
|
||||
if nestedErrs, ok := err.(validation.Errors); ok {
|
||||
result[name] = resolveValidationErrors(nestedErrs)
|
||||
for name, err := range data {
|
||||
if isNestedError(err) {
|
||||
result[name] = safeErrorsData(err)
|
||||
continue
|
||||
}
|
||||
|
||||
errCode := "validation_invalid_value" // default
|
||||
if errObj, ok := err.(validation.ErrorObject); ok {
|
||||
errCode = errObj.Code()
|
||||
}
|
||||
|
||||
result[name] = map[string]string{
|
||||
"code": errCode,
|
||||
"message": inflector.Sentenize(err.Error()),
|
||||
}
|
||||
result[name] = resolveSafeErrorItem(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func isNestedError(err any) bool {
|
||||
switch err.(type) {
|
||||
case validation.Errors, map[string]validation.Error, map[string]error, map[string]any:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveSafeErrorItem extracts from each validation error its
|
||||
// public safe error code and message.
|
||||
func resolveSafeErrorItem(err any) map[string]string {
|
||||
// default public safe error values
|
||||
code := "validation_invalid_value"
|
||||
msg := "Invalid value."
|
||||
|
||||
// only validation errors are public safe
|
||||
if obj, ok := err.(validation.Error); ok {
|
||||
code = obj.Code()
|
||||
msg = inflector.Sentenize(obj.Error())
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"code": code,
|
||||
"message": msg,
|
||||
}
|
||||
}
|
||||
|
||||
+24
-12
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
@@ -33,14 +35,16 @@ func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
"err1": errors.New("test error"),
|
||||
"err1": errors.New("test error"), // should be normalized
|
||||
"err2": validation.ErrRequired,
|
||||
"err3": validation.Errors{
|
||||
"sub1": errors.New("test error"),
|
||||
"sub1": errors.New("test error"), // should be normalized
|
||||
"sub2": validation.ErrRequired,
|
||||
"sub3": validation.Errors{
|
||||
"sub11": validation.ErrRequired,
|
||||
@@ -50,10 +54,10 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
)
|
||||
|
||||
result, _ := json.Marshal(e)
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Invalid value."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Invalid value."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
|
||||
|
||||
if string(result) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(result))
|
||||
t.Errorf("Expected \n%v, \ngot \n%v", expected, string(result))
|
||||
}
|
||||
|
||||
if e.Error() != "Message_test." {
|
||||
@@ -66,6 +70,8 @@ func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewNotFoundError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -73,7 +79,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -81,12 +87,14 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -94,7 +102,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -102,12 +110,14 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForbiddenError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -115,7 +125,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -123,12 +133,14 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUnauthorizedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
message string
|
||||
data any
|
||||
@@ -136,7 +148,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}{
|
||||
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
|
||||
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
|
||||
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
|
||||
{"demo", validation.Errors{"err1": validation.NewError("test_code", "test_message")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"test_code","message":"Test_message."}}}`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -144,7 +156,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
|
||||
t.Errorf("(%d) Expected \n%v, \ngot \n%v", i, scenario.expected, string(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// bindBackupApi registers the file api endpoints and the corresponding handlers.
|
||||
//
|
||||
// @todo add hooks once the app hooks api restructuring is finalized
|
||||
func bindBackupApi(app core.App, rg *echo.Group) {
|
||||
api := backupApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/backups", ActivityLogger(app))
|
||||
subGroup.GET("", api.list, RequireAdminAuth())
|
||||
subGroup.POST("", api.create, RequireAdminAuth())
|
||||
subGroup.POST("/upload", api.upload, RequireAdminAuth())
|
||||
subGroup.GET("/:key", api.download)
|
||||
subGroup.DELETE("/:key", api.delete, RequireAdminAuth())
|
||||
subGroup.POST("/:key/restore", api.restore, RequireAdminAuth())
|
||||
}
|
||||
|
||||
type backupApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func (api *backupApi) list(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
backups, err := fsys.List("")
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
result := make([]models.BackupFileInfo, len(backups))
|
||||
|
||||
for i, obj := range backups {
|
||||
modified, _ := types.ParseDateTime(obj.ModTime)
|
||||
|
||||
result[i] = models.BackupFileInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
Modified: modified,
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *backupApi) create(c echo.Context) error {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
|
||||
}
|
||||
|
||||
form := forms.NewBackupCreate(api.app)
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
|
||||
return func(name string) error {
|
||||
if err := next(name); err != nil {
|
||||
return NewBadRequestError("Failed to create backup.", err)
|
||||
}
|
||||
|
||||
// we don't retrieve the generated backup file because it may not be
|
||||
// available yet due to the eventually consistent nature of some S3 providers
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (api *backupApi) upload(c echo.Context) error {
|
||||
files, err := rest.FindUploadedFiles(c.Request(), "file")
|
||||
if err != nil {
|
||||
return NewBadRequestError("Missing or invalid uploaded file.", err)
|
||||
}
|
||||
|
||||
form := forms.NewBackupUpload(api.app)
|
||||
form.File = files[0]
|
||||
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*filesystem.File]) forms.InterceptorNextFunc[*filesystem.File] {
|
||||
return func(file *filesystem.File) error {
|
||||
if err := next(file); err != nil {
|
||||
return NewBadRequestError("Failed to upload backup.", err)
|
||||
}
|
||||
|
||||
// we don't retrieve the generated backup file because it may not be
|
||||
// available yet due to the eventually consistent nature of some S3 providers
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (api *backupApi) download(c echo.Context) error {
|
||||
fileToken := c.QueryParam("token")
|
||||
|
||||
_, err := api.app.Dao().FindAdminByToken(
|
||||
fileToken,
|
||||
api.app.Settings().AdminFileToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return NewForbiddenError("Insufficient permissions to access the resource.", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
br, err := fsys.GetFile(key)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer br.Close()
|
||||
|
||||
return fsys.Serve(
|
||||
c.Response(),
|
||||
c.Request(),
|
||||
key,
|
||||
filepath.Base(key), // without the path prefix (if any)
|
||||
)
|
||||
}
|
||||
|
||||
func (api *backupApi) restore(c echo.Context) error {
|
||||
if api.app.Store().Has(core.StoreKeyActiveBackup) {
|
||||
return NewBadRequestError("Try again later - another backup/restore process has already been started.", nil)
|
||||
}
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(existsCtx)
|
||||
|
||||
if exists, err := fsys.Exists(key); !exists {
|
||||
return NewBadRequestError("Missing or invalid backup file.", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
// wait max 15 minutes to fetch the backup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// give some optimistic time to write the response
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := api.app.RestoreBackup(ctx, key); err != nil {
|
||||
api.app.Logger().Error("Failed to restore backup", "key", key, "error", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *backupApi) delete(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fsys, err := api.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to load backups filesystem.", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
key := c.PathParam("key")
|
||||
|
||||
if key != "" && cast.ToString(api.app.Store().Get(core.StoreKeyActiveBackup)) == key {
|
||||
return NewBadRequestError("The backup is currently being used and cannot be deleted.", nil)
|
||||
}
|
||||
|
||||
if err := fsys.Delete(key); err != nil {
|
||||
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"gocloud.dev/blob"
|
||||
)
|
||||
|
||||
func TestBackupsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (empty list)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"test1.zip"`,
|
||||
`"test2.zip"`,
|
||||
`"test3.zip"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (pending backup)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (autogenerated name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
}
|
||||
|
||||
expected := "pb_backup_"
|
||||
if !strings.HasPrefix(files[0].Key, expected) {
|
||||
t.Fatalf("Expected backup file with prefix %q, got %q", expected, files[0].Key)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"!test.zip"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"name":{"code":"validation_match_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups",
|
||||
Body: strings.NewReader(`{"name":"test.zip"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
}
|
||||
|
||||
expected := "test.zip"
|
||||
if files[0].Key != expected {
|
||||
t.Fatalf("Expected backup file %q, got %q", expected, files[0].Key)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsUpload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// create dummy form data bodies
|
||||
type body struct {
|
||||
buffer io.Reader
|
||||
contentType string
|
||||
}
|
||||
bodies := make([]body, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
func() {
|
||||
zb := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(zb)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
mw := multipart.NewWriter(b)
|
||||
|
||||
mfw, err := mw.CreateFormFile("file", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(mfw, zb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mw.Close()
|
||||
|
||||
bodies[i] = body{
|
||||
buffer: b,
|
||||
contentType: mw.FormDataContentType(),
|
||||
}
|
||||
}()
|
||||
}
|
||||
// ---
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[0].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[0].contentType,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[1].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[1].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureNoBackups(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing backup name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[3].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[3].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
// create a dummy backup file to simulate existing backups
|
||||
if err := fsys.Upload([]byte("123"), "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{"file":{`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/upload",
|
||||
Body: bodies[4].buffer,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": bodies[4].contentType,
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, _ := getBackupFiles(app)
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected %d backup file, got %d", 1, total)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with record auth header",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with admin auth header",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with empty or invalid token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid record auth token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid record file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin auth token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with expired admin file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token but missing backup name",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/missing?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/test1.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`storage/`,
|
||||
`data.db`,
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with valid admin file token and backup name with escaped char",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/backups/%40test4.zip?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`storage/`,
|
||||
`data.db`,
|
||||
`logs.db`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noTestBackupFilesChanges := func(t *testing.T, app *tests.TestApp) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := 4
|
||||
if total := len(files); total != expected {
|
||||
t.Fatalf("Expected %d backup(s), got %d", expected, total)
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/missing.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing file with matching active backup)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock active backup with the same name to delete
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "test1.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
noTestBackupFilesChanges(t, app)
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (existing file and no matching active backup)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/test1.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock active backup with different name
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "new.zip")
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "test1.zip"
|
||||
|
||||
for _, f := range files {
|
||||
if f.Key == deletedFile {
|
||||
t.Fatalf("Expected backup %q to be deleted", deletedFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (backup with escaped character)",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/backups/%40test4.zip",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 3 {
|
||||
t.Fatalf("Expected %d backup files, got %d", 3, total)
|
||||
}
|
||||
|
||||
deletedFile := "@test4.zip"
|
||||
|
||||
for _, f := range files {
|
||||
if f.Key == deletedFile {
|
||||
t.Fatalf("Expected backup %q to be deleted", deletedFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 204,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupsRestore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (missing file)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/missing.zip/restore",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (active backup process)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/backups/test1.zip/restore",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := createTestBackups(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func createTestBackups(app core.App) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if err := app.CreateBackup(ctx, "test1.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "test2.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "test3.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.CreateBackup(ctx, "@test4.zip"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBackupFiles(app core.App) ([]*blob.ListObject, error) {
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
return fsys.List("")
|
||||
}
|
||||
|
||||
func ensureNoBackups(t *testing.T, app *tests.TestApp) {
|
||||
files, err := getBackupFiles(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if total := len(files); total != 0 {
|
||||
t.Fatalf("Expected 0 backup files, got %d", total)
|
||||
}
|
||||
}
|
||||
+78
-50
@@ -2,18 +2,21 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/ui"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
@@ -24,51 +27,63 @@ const trailedAdminPath = "/_/"
|
||||
// system and app specific routes and middlewares.
|
||||
func InitApi(app core.App) (*echo.Echo, error) {
|
||||
e := echo.New()
|
||||
e.Debug = app.IsDebug()
|
||||
e.Debug = false
|
||||
e.Binder = &rest.MultiBinder{}
|
||||
e.JSONSerializer = &rest.Serializer{
|
||||
FieldsParam: fieldsQueryParam,
|
||||
}
|
||||
|
||||
// configure a custom router
|
||||
e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
|
||||
return echo.NewRouter(echo.RouterConfig{
|
||||
UnescapePathParamValues: true,
|
||||
AllowOverwritingRoute: true,
|
||||
})
|
||||
})
|
||||
|
||||
// default middlewares
|
||||
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
|
||||
Skipper: func(c echo.Context) bool {
|
||||
// ignore Admin UI route(s)
|
||||
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
|
||||
// enable by default only for the API routes
|
||||
return !strings.HasPrefix(c.Request().URL.Path, "/api/")
|
||||
},
|
||||
}))
|
||||
e.Pre(LoadAuthContext(app))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
e.Use(LoadAuthContext(app))
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set(ContextExecStartKey, time.Now())
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
// custom error handler
|
||||
e.HTTPErrorHandler = func(c echo.Context, err error) {
|
||||
if c.Response().Committed {
|
||||
return
|
||||
if err == nil {
|
||||
return // no error
|
||||
}
|
||||
|
||||
var apiErr *ApiError
|
||||
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
if v.Internal != nil && app.IsDebug() {
|
||||
log.Println(v.Internal)
|
||||
}
|
||||
if errors.As(err, &apiErr) {
|
||||
// already an api error...
|
||||
} else if v := new(echo.HTTPError); errors.As(err, &v) {
|
||||
msg := fmt.Sprintf("%v", v.Message)
|
||||
apiErr = NewApiError(v.Code, msg, v)
|
||||
case *ApiError:
|
||||
if app.IsDebug() && v.RawData() != nil {
|
||||
log.Println(v.RawData())
|
||||
} else {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
apiErr = NewNotFoundError("", err)
|
||||
} else {
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
apiErr = v
|
||||
default:
|
||||
if err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
apiErr = NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
logRequest(app, c, apiErr)
|
||||
|
||||
if c.Response().Committed {
|
||||
return // already committed
|
||||
}
|
||||
|
||||
event := new(core.ApiErrorEvent)
|
||||
@@ -77,6 +92,10 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
|
||||
// send error response
|
||||
hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @see https://github.com/labstack/echo/issues/608
|
||||
if e.HttpContext.Request().Method == http.MethodHead {
|
||||
return e.HttpContext.NoContent(apiErr.Code)
|
||||
@@ -85,19 +104,20 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
return e.HttpContext.JSON(apiErr.Code, apiErr)
|
||||
})
|
||||
|
||||
// truly rare case; eg. client already disconnected
|
||||
if hookErr != nil && app.IsDebug() {
|
||||
log.Println(hookErr)
|
||||
if hookErr == nil {
|
||||
if err := app.OnAfterApiError().Trigger(event); err != nil {
|
||||
app.Logger().Debug("OnAfterApiError failure", slog.String("error", err.Error()))
|
||||
}
|
||||
} else {
|
||||
app.Logger().Debug("OnBeforeApiError error (truly rare case, eg. client already disconnected)", slog.String("error", hookErr.Error()))
|
||||
}
|
||||
|
||||
app.OnAfterApiError().Trigger(event)
|
||||
}
|
||||
|
||||
// admin ui routes
|
||||
bindStaticAdminUI(app, e)
|
||||
|
||||
// default routes
|
||||
api := e.Group("/api")
|
||||
api := e.Group("/api", eagerRequestInfoCache(app))
|
||||
bindSettingsApi(app, api)
|
||||
bindAdminApi(app, api)
|
||||
bindCollectionApi(app, api)
|
||||
@@ -107,16 +127,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
|
||||
bindRealtimeApi(app, api)
|
||||
bindLogsApi(app, api)
|
||||
bindHealthApi(app, api)
|
||||
|
||||
// trigger the custom BeforeServe hook for the created api router
|
||||
// allowing users to further adjust its options or register new routes
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: e,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindBackupApi(app, api)
|
||||
|
||||
// catch all any route
|
||||
api.Any("/*", func(c echo.Context) error {
|
||||
@@ -173,21 +184,36 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
|
||||
trailedAdminPath+"*",
|
||||
echo.StaticDirectoryHandler(ui.DistDirFS, false),
|
||||
installerRedirect(app),
|
||||
uiCacheControl(),
|
||||
middleware.Gzip(),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const totalAdminsCacheKey = "totalAdmins"
|
||||
func uiCacheControl() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// add default Cache-Control header for all Admin UI resources
|
||||
// (ignoring the root admin path)
|
||||
if c.Request().URL.Path != trailedAdminPath {
|
||||
c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
|
||||
}
|
||||
|
||||
func updateTotalAdminsCache(app core.App) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdminsCacheKey = "@hasAdmins"
|
||||
|
||||
func updateHasAdminsCache(app core.App) error {
|
||||
total, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.Cache().Set(totalAdminsCacheKey, total)
|
||||
app.Store().Set(hasAdminsCacheKey, total > 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -195,12 +221,13 @@ func updateTotalAdminsCache(app core.App) error {
|
||||
// installerRedirect redirects the user to the installer admin UI page
|
||||
// when the application needs some preliminary configurations to be done.
|
||||
func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
// keep totalAdminsCacheKey value up-to-date
|
||||
// keep hasAdminsCacheKey value up-to-date
|
||||
app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
|
||||
return updateTotalAdminsCache(app)
|
||||
return updateHasAdminsCache(app)
|
||||
})
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
@@ -211,23 +238,24 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// load into cache (if not already)
|
||||
if !app.Cache().Has(totalAdminsCacheKey) {
|
||||
if err := updateTotalAdminsCache(app); err != nil {
|
||||
hasAdmins := cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
|
||||
if !hasAdmins {
|
||||
// update the cache to make sure that the admin wasn't created by another process
|
||||
if err := updateHasAdminsCache(app); err != nil {
|
||||
return err
|
||||
}
|
||||
hasAdmins = cast.ToBool(app.Store().Get(hasAdminsCacheKey))
|
||||
}
|
||||
|
||||
totalAdmins := cast.ToInt(app.Cache().Get(totalAdminsCacheKey))
|
||||
|
||||
_, hasInstallerParam := c.Request().URL.Query()["installer"]
|
||||
|
||||
if totalAdmins == 0 && !hasInstallerParam {
|
||||
if !hasAdmins && !hasInstallerParam {
|
||||
// redirect to the installer page
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
|
||||
}
|
||||
|
||||
if totalAdmins != 0 && hasInstallerParam {
|
||||
if hasAdmins && hasInstallerParam {
|
||||
// clear the installer param
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "?")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func Test404(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
@@ -49,6 +56,8 @@ func Test404(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "custom route",
|
||||
@@ -136,3 +145,278 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrailingSlashMiddleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "non /api/* route (exact match)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/custom",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "non /api/* route (with trailing slash)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/custom/",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "/api/* route (exact match)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/custom",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
{
|
||||
Name: "/api/* route (with trailing slash)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/custom/",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: http.MethodGet,
|
||||
Path: "/api/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
return c.String(200, "test123")
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiBinder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawJson := `{"name":"test123"}`
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
rest.MultipartJsonKey: rawJson,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "non-api group route",
|
||||
Method: "POST",
|
||||
Url: "/custom",
|
||||
Body: strings.NewReader(rawJson),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: "POST",
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
|
||||
if err := c.Bind(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return c.NoContent(200)
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
{
|
||||
Name: "api group route",
|
||||
Method: "GET",
|
||||
Url: "/api/admins",
|
||||
Body: strings.NewReader(rawJson),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// it is not important whether the route handler return an error since
|
||||
// we just need to ensure that the eagerRequestInfoCache was registered
|
||||
next(c)
|
||||
|
||||
// ensure that the body was read at least once
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
c.Bind(data)
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
{
|
||||
Name: "custom route with @jsonPayload as multipart body",
|
||||
Method: "POST",
|
||||
Url: "/custom",
|
||||
Body: formData,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": mp.FormDataContentType(),
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.AddRoute(echo.Route{
|
||||
Method: "POST",
|
||||
Path: "/custom",
|
||||
Handler: func(c echo.Context) error {
|
||||
data := &struct {
|
||||
Name string `json:"name"`
|
||||
}{}
|
||||
|
||||
if err := c.Bind(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// try to read the body again
|
||||
r := apis.RequestInfo(c)
|
||||
if v := cast.ToString(r.Data["name"]); v != "test123" {
|
||||
t.Fatalf("Expected request data with name %q, got, %q", "test123", v)
|
||||
}
|
||||
|
||||
return c.NoContent(200)
|
||||
},
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return apis.NewApiError(418, "test", nil)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped apis.ApiError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", apis.NewApiError(418, "test", nil))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(418, "test")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
},
|
||||
{
|
||||
Name: "wrapped echo.HTTPError",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", echo.NewHTTPError(418, "test"))
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 418,
|
||||
ExpectedContent: []string{`"message":"Test."`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "wrapped sql.ErrNoRows",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123: %w", sql.ErrNoRows)
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
{
|
||||
Name: "custom error",
|
||||
Method: http.MethodGet,
|
||||
Url: "/test",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
e.GET("/test", func(c echo.Context) error {
|
||||
return fmt.Errorf("example 123")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
NotExpectedContent: []string{"example", "123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-42
@@ -1,7 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -49,6 +48,10 @@ func (api *collectionApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnCollectionsListRequest().Trigger(event, func(e *core.CollectionsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
})
|
||||
}
|
||||
@@ -64,6 +67,10 @@ func (api *collectionApi) view(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
return api.app.OnCollectionViewRequest().Trigger(event, func(e *core.CollectionViewEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
}
|
||||
@@ -83,7 +90,7 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// create the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -92,18 +99,16 @@ func (api *collectionApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) update(c echo.Context) error {
|
||||
@@ -124,7 +129,7 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
|
||||
// update the collection
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(m *models.Collection) error {
|
||||
event.Collection = m
|
||||
|
||||
@@ -133,18 +138,16 @@ func (api *collectionApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update the collection.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
return api.app.OnCollectionAfterUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Collection)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) delete(c echo.Context) error {
|
||||
@@ -157,21 +160,19 @@ func (api *collectionApi) delete(c echo.Context) error {
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
return api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
|
||||
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
|
||||
return NewBadRequestError("Failed to delete collection due to existing dependency.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionAfterDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnCollectionAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
@@ -187,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
event.Collections = form.Collections
|
||||
|
||||
// import collections
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[[]*models.Collection]) forms.InterceptorNextFunc[[]*models.Collection] {
|
||||
return func(imports []*models.Collection) error {
|
||||
event.Collections = imports
|
||||
|
||||
@@ -196,16 +197,14 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to import the submitted collections.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnCollectionsAfterImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnCollectionsAfterImportRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
+301
-104
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -9,13 +10,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestCollectionsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -45,7 +48,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"_pb_users_auth_"`,
|
||||
`"id":"v851q4r790rhknl"`,
|
||||
@@ -55,6 +58,7 @@ func TestCollectionsList(t *testing.T) {
|
||||
`"id":"wzlqyes4orhoygb"`,
|
||||
`"id":"4d1blo5cuycfaca"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
`"id":"ib3m2700k5hlsjz"`,
|
||||
`"type":"auth"`,
|
||||
`"type":"base"`,
|
||||
},
|
||||
@@ -73,9 +77,9 @@ func TestCollectionsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"page":2`,
|
||||
`"perPage":2`,
|
||||
`"totalItems":10`,
|
||||
`"totalItems":11`,
|
||||
`"items":[{`,
|
||||
`"id":"kpv709sk2lqbqk8"`,
|
||||
`"id":"v9gwnfh02gjq1q0"`,
|
||||
`"id":"9n89pl5vkct6330"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -123,6 +127,8 @@ func TestCollectionsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -191,6 +197,8 @@ func TestCollectionView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
|
||||
storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
|
||||
|
||||
@@ -243,7 +251,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
@@ -262,7 +270,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "9n89pl5vkct6330")
|
||||
},
|
||||
},
|
||||
@@ -299,7 +307,6 @@ func TestCollectionDelete(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
Delay: 100 * time.Millisecond,
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
@@ -308,6 +315,27 @@ func TestCollectionDelete(t *testing.T) {
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/view2",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterDeleteRequest().Add(func(e *core.CollectionDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnCollectionBeforeDeleteRequest": 1,
|
||||
"OnCollectionAfterDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -316,6 +344,8 @@ func TestCollectionDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -378,7 +408,7 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"new"`,
|
||||
`"type":"base"`,
|
||||
`"system":false`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
@@ -402,8 +432,8 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"name":"new"`,
|
||||
`"type":"auth"`,
|
||||
`"system":false`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
|
||||
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
|
||||
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"onlyVerified":false,"requireEmail":false}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
@@ -536,6 +566,28 @@ func TestCollectionCreate(t *testing.T) {
|
||||
`"options":{"minPasswordLength":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterCreateRequest().Add(func(e *core.CollectionCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
@@ -586,6 +638,83 @@ func TestCollectionCreate(t *testing.T) {
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// indexes
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "creating base collection with invalid indexes",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"base",
|
||||
"schema":[
|
||||
{"type":"text","name":"test"}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test1 on new (test)",
|
||||
"create index idx_test2 on new (missing)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"indexes":{"1":{"code":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "creating base collection with valid indexes (+ random table name)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections",
|
||||
Body: strings.NewReader(`{
|
||||
"name":"new",
|
||||
"type":"base",
|
||||
"schema":[
|
||||
{"type":"text","name":"test"}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test1 on new (test)",
|
||||
"create index idx_test2 on anything (id, test)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new"`,
|
||||
`"type":"base"`,
|
||||
`"indexes":[`,
|
||||
`idx_test1`,
|
||||
`idx_test2`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnCollectionBeforeCreateRequest": 1,
|
||||
"OnCollectionAfterCreateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{"idx_test1", "idx_test2"}
|
||||
for name := range indexes {
|
||||
if !list.ExistInSlice(name, expected) {
|
||||
t.Fatalf("Missing index %q", name)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -594,6 +723,8 @@ func TestCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -643,6 +774,28 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnCollectionAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo1",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionAfterUpdateRequest().Add(func(e *core.CollectionUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + invalid data (eg. existing name)",
|
||||
Method: http.MethodPatch,
|
||||
@@ -680,7 +833,7 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
// check if the record table was renamed
|
||||
if !app.Dao().HasTable("new") {
|
||||
t.Fatal("Couldn't find record table 'new'.")
|
||||
@@ -740,7 +893,8 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
{"type":"text","name":"password"},
|
||||
{"type":"text","name":"passwordConfirm"},
|
||||
{"type":"text","name":"oldPassword"}
|
||||
]
|
||||
],
|
||||
"indexes": []
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
@@ -816,88 +970,6 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
|
||||
// rel field change displayFields propagation
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "renaming a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"id": "w5z2x0nq",
|
||||
"type": "text",
|
||||
"name": "title_change"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"title_change"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"title_change", "id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deleting a display field should also update the referenced displayFields value",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3",
|
||||
Body: strings.NewReader(`{
|
||||
"schema":[
|
||||
{
|
||||
"type": "text",
|
||||
"name": "new_field"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"new_field"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relField := collection.Schema.GetFieldByName("rel_many_no_cascade_required")
|
||||
options := relField.Options.(*schema.RelationOptions)
|
||||
expectedDisplayFields := []string{"id"}
|
||||
if len(list.SubtractSlice(options.DisplayFields, expectedDisplayFields)) != 0 {
|
||||
t.Fatalf("Expected displayFields %v, got %v", expectedDisplayFields, options.DisplayFields)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// view
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
@@ -951,6 +1023,68 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// indexes
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
Name: "updating base collection with invalid indexes",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2",
|
||||
Body: strings.NewReader(`{
|
||||
"indexes": [
|
||||
"create unique idx_test1 on demo1 (text)",
|
||||
"create index idx_test2 on demo2 (id, title)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"indexes":{"0":{"code":"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "updating base collection with valid indexes (+ random table name)",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2",
|
||||
Body: strings.NewReader(`{
|
||||
"indexes": [
|
||||
"create unique index idx_test1 on demo2 (title)",
|
||||
"create index idx_test2 on anything (active)"
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"name":"demo2"`,
|
||||
`"indexes":[`,
|
||||
"CREATE UNIQUE INDEX `idx_test1`",
|
||||
"CREATE INDEX `idx_test2`",
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnCollectionBeforeUpdateRequest": 1,
|
||||
"OnCollectionAfterUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
indexes, err := app.Dao().TableIndexes("new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := []string{"idx_test1", "idx_test2"}
|
||||
for name := range indexes {
|
||||
if !list.ExistInSlice(name, expected) {
|
||||
t.Fatalf("Missing index %q", name)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -958,8 +1092,10 @@ func TestCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionImport(t *testing.T) {
|
||||
totalCollections := 10
|
||||
func TestCollectionsImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
@@ -992,7 +1128,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
`"data":{`,
|
||||
`"collections":{"code":"validation_required"`,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1018,9 +1154,9 @@ func TestCollectionImport(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 6,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1062,7 +1198,7 @@ func TestCollectionImport(t *testing.T) {
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1097,6 +1233,9 @@ func TestCollectionImport(t *testing.T) {
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1115,15 +1254,21 @@ func TestCollectionImport(t *testing.T) {
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := totalCollections + 3
|
||||
if len(collections) != expected {
|
||||
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
|
||||
}
|
||||
|
||||
indexes, err := app.Dao().TableIndexes("import2")
|
||||
if err != nil || indexes["idx_test"] == "" {
|
||||
t.Fatalf("Missing index %s (%v)", "idx_test", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1207,14 +1352,14 @@ func TestCollectionImport(t *testing.T) {
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnModelBeforeDelete": 8,
|
||||
"OnModelAfterDelete": 8,
|
||||
"OnModelBeforeDelete": 9,
|
||||
"OnModelAfterDelete": 9,
|
||||
"OnModelBeforeUpdate": 2,
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
collections := []*models.Collection{}
|
||||
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1225,6 +1370,58 @@ func TestCollectionImport(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin + successful collections save",
|
||||
Method: http.MethodPut,
|
||||
Url: "/api/collections/import",
|
||||
Body: strings.NewReader(`{
|
||||
"collections":[
|
||||
{
|
||||
"name": "import1",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "import2",
|
||||
"schema": [
|
||||
{
|
||||
"id": "koih1lqx",
|
||||
"name": "test",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"create index idx_test on import2 (test)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "auth_without_schema",
|
||||
"type": "auth"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnCollectionsBeforeImportRequest": 1,
|
||||
"OnCollectionsAfterImportRequest": 1,
|
||||
"OnModelBeforeCreate": 3,
|
||||
"OnModelAfterCreate": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+167
-15
@@ -1,13 +1,26 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
|
||||
@@ -15,15 +28,62 @@ var defaultThumbSizes = []string{"100x100"}
|
||||
|
||||
// bindFileApi registers the file api endpoints and the corresponding handlers.
|
||||
func bindFileApi(app core.App, rg *echo.Group) {
|
||||
api := fileApi{app: app}
|
||||
api := fileApi{
|
||||
app: app,
|
||||
thumbGenSem: semaphore.NewWeighted(int64(runtime.NumCPU() + 2)), // the value is arbitrary chosen and may change in the future
|
||||
thumbGenPending: new(singleflight.Group),
|
||||
thumbGenMaxWait: 60 * time.Second,
|
||||
}
|
||||
|
||||
subGroup := rg.Group("/files", ActivityLogger(app))
|
||||
subGroup.POST("/token", api.fileToken)
|
||||
subGroup.HEAD("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
subGroup.GET("/:collection/:recordId/:filename", api.download, LoadCollectionContext(api.app))
|
||||
}
|
||||
|
||||
type fileApi struct {
|
||||
app core.App
|
||||
|
||||
// thumbGenSem is a semaphore to prevent too much concurrent
|
||||
// requests generating new thumbs at the same time.
|
||||
thumbGenSem *semaphore.Weighted
|
||||
|
||||
// thumbGenPending represents a group of currently pending
|
||||
// thumb generation processes.
|
||||
thumbGenPending *singleflight.Group
|
||||
|
||||
// thumbGenMaxWait is the maximum waiting time for starting a new
|
||||
// thumb generation process.
|
||||
thumbGenMaxWait time.Duration
|
||||
}
|
||||
|
||||
func (api *fileApi) fileToken(c echo.Context) error {
|
||||
event := new(core.FileTokenEvent)
|
||||
event.HttpContext = c
|
||||
|
||||
if admin, _ := c.Get(ContextAdminKey).(*models.Admin); admin != nil {
|
||||
event.Model = admin
|
||||
event.Token, _ = tokens.NewAdminFileToken(api.app, admin)
|
||||
} else if record, _ := c.Get(ContextAuthRecordKey).(*models.Record); record != nil {
|
||||
event.Model = record
|
||||
event.Token, _ = tokens.NewRecordFileToken(api.app, record)
|
||||
}
|
||||
|
||||
return api.app.OnFileBeforeTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.Model == nil || e.Token == "" {
|
||||
return NewBadRequestError("Failed to generate file token.", nil)
|
||||
}
|
||||
|
||||
return api.app.OnFileAfterTokenRequest().Trigger(event, func(e *core.FileTokenEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, map[string]string{
|
||||
"token": e.Token,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *fileApi) download(c echo.Context) error {
|
||||
@@ -49,7 +109,34 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
options, _ := fileField.Options.(*schema.FileOptions)
|
||||
options, ok := fileField.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
return NewBadRequestError("", errors.New("Failed to load file options."))
|
||||
}
|
||||
|
||||
// check whether the request is authorized to view the protected file
|
||||
if options.Protected {
|
||||
token := c.QueryParam("token")
|
||||
|
||||
adminOrAuthRecord, _ := api.findAdminOrAuthRecordByFileToken(token)
|
||||
|
||||
// create a copy of the cached request data and adjust it for the current auth model
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Context = models.RequestInfoContextProtectedFile
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = nil
|
||||
if adminOrAuthRecord != nil {
|
||||
if admin, _ := adminOrAuthRecord.(*models.Admin); admin != nil {
|
||||
requestInfo.Admin = admin
|
||||
} else if record, _ := adminOrAuthRecord.(*models.Record); record != nil {
|
||||
requestInfo.AuthRecord = record
|
||||
}
|
||||
}
|
||||
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, &requestInfo, record.Collection().ViewRule); !ok {
|
||||
return NewForbiddenError("Insufficient permissions to access the file resource.", nil)
|
||||
}
|
||||
}
|
||||
|
||||
baseFilesPath := record.BaseFilesPath()
|
||||
|
||||
@@ -62,11 +149,11 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
baseFilesPath = fileRecord.BaseFilesPath()
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
fsys, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Filesystem initialization failure.", err)
|
||||
}
|
||||
defer fs.Close()
|
||||
defer fsys.Close()
|
||||
|
||||
originalPath := baseFilesPath + "/" + filename
|
||||
servedPath := originalPath
|
||||
@@ -76,7 +163,7 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
thumbSize := c.QueryParam("thumb")
|
||||
if thumbSize != "" && (list.ExistInSlice(thumbSize, defaultThumbSizes) || list.ExistInSlice(thumbSize, options.Thumbs)) {
|
||||
// extract the original file meta attributes and check it existence
|
||||
oAttrs, oAttrsErr := fs.Attributes(originalPath)
|
||||
oAttrs, oAttrsErr := fsys.Attributes(originalPath)
|
||||
if oAttrsErr != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
@@ -87,13 +174,19 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
servedName = thumbSize + "_" + filename
|
||||
servedPath = baseFilesPath + "/thumbs_" + filename + "/" + servedName
|
||||
|
||||
// check if the thumb exists:
|
||||
// - if doesn't exist - create a new thumb with the specified thumb size
|
||||
// - if exists - compare last modified dates to determine whether the thumb should be recreated
|
||||
tAttrs, tAttrsErr := fs.Attributes(servedPath)
|
||||
if tAttrsErr != nil || oAttrs.ModTime.After(tAttrs.ModTime) {
|
||||
if err := fs.CreateThumb(originalPath, servedPath, thumbSize); err != nil {
|
||||
servedPath = originalPath // fallback to the original
|
||||
// create a new thumb if it doesn't exist
|
||||
if exists, _ := fsys.Exists(servedPath); !exists {
|
||||
if err := api.createThumb(c, fsys, originalPath, servedPath, thumbSize); err != nil {
|
||||
api.app.Logger().Warn(
|
||||
"Fallback to original - failed to create thumb "+servedName,
|
||||
slog.Any("error", err),
|
||||
slog.String("original", originalPath),
|
||||
slog.String("thumb", servedPath),
|
||||
)
|
||||
|
||||
// fallback to the original
|
||||
servedName = filename
|
||||
servedPath = originalPath
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,12 +206,71 @@ func (api *fileApi) download(c echo.Context) error {
|
||||
c.Response().Header().Del("X-Frame-Options")
|
||||
|
||||
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
|
||||
res := e.HttpContext.Response()
|
||||
req := e.HttpContext.Request()
|
||||
if err := fs.Serve(res, req, e.ServedPath, e.ServedName); err != nil {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fsys.Serve(e.HttpContext.Response(), e.HttpContext.Request(), e.ServedPath, e.ServedName); err != nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (api *fileApi) findAdminOrAuthRecordByFileToken(fileToken string) (models.Model, error) {
|
||||
fileToken = strings.TrimSpace(fileToken)
|
||||
if fileToken == "" {
|
||||
return nil, errors.New("missing file token")
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(strings.TrimSpace(fileToken))
|
||||
tokenType := cast.ToString(claims["type"])
|
||||
|
||||
switch tokenType {
|
||||
case tokens.TypeAdmin:
|
||||
admin, err := api.app.Dao().FindAdminByToken(
|
||||
fileToken,
|
||||
api.app.Settings().AdminFileToken.Secret,
|
||||
)
|
||||
if err == nil && admin != nil {
|
||||
return admin, nil
|
||||
}
|
||||
case tokens.TypeAuthRecord:
|
||||
record, err := api.app.Dao().FindAuthRecordByToken(
|
||||
fileToken,
|
||||
api.app.Settings().RecordFileToken.Secret,
|
||||
)
|
||||
if err == nil && record != nil {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("missing or invalid file token")
|
||||
}
|
||||
|
||||
func (api *fileApi) createThumb(
|
||||
c echo.Context,
|
||||
fsys *filesystem.System,
|
||||
originalPath string,
|
||||
thumbPath string,
|
||||
thumbSize string,
|
||||
) error {
|
||||
ch := api.thumbGenPending.DoChan(thumbPath, func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), api.thumbGenMaxWait)
|
||||
defer cancel()
|
||||
|
||||
if err := api.thumbGenSem.Acquire(ctx, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer api.thumbGenSem.Release(1)
|
||||
|
||||
return nil, fsys.CreateThumb(originalPath, thumbPath, thumbSize)
|
||||
})
|
||||
|
||||
res := <-ch
|
||||
|
||||
api.thumbGenPending.Forget(thumbPath)
|
||||
|
||||
return res.Err
|
||||
}
|
||||
|
||||
@@ -2,16 +2,100 @@ package apis_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestFileToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unauthorized with model and token via hook",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnFileBeforeTokenRequest().Add(func(e *core.FileTokenEvent) error {
|
||||
record, _ := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
e.Model = record
|
||||
e.Token = "test"
|
||||
return nil
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"test"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admin",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/files/token",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileBeforeTokenRequest": 1,
|
||||
"OnFileAfterTokenRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, currentFile, _, _ := runtime.Caller(0)
|
||||
dataDirRelPath := "../tests/data/"
|
||||
|
||||
@@ -176,6 +260,125 @@ func TestFileDownload(t *testing.T) {
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// protected file access checks
|
||||
{
|
||||
Name: "protected file - expired token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{string(testFile)},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - admin with expired file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTY0MDk5MTY2MSwidHlwZSI6ImFkbWluIn0.g7Q_3UX6H--JWJ7yt1Hoe-1ugTX1KpbKzdt0zjGSe-E",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - admin with valid file token",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsImV4cCI6MTg5MzQ1MjQ2MSwidHlwZSI6ImFkbWluIn0.LyAMpSfaHVsuUqIlqqEbhDQSdFzoPz_EIDcb2VJMBsU",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - guest without view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - guest with view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock public view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file - auth record without view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock restricted user view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("@request.auth.verified = true")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file - auth record with view access",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/demo1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
dao := daos.New(app.Dao().DB())
|
||||
|
||||
// mock user view access
|
||||
c, err := dao.FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch mock collection: %v", err)
|
||||
}
|
||||
c.ViewRule = types.Pointer("@request.auth.verified = false")
|
||||
if err := dao.SaveCollection(c); err != nil {
|
||||
t.Fatalf("Failed to update mock collection: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"PNG"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "protected file in view (view's View API rule failure)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/view1/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "protected file in view (view's View API rule success)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/files/view1/84nmscqy84lsi1t/test_d61b33QdDU.txt?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImV4cCI6MTg5MzQ1MjQ2MSwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwidHlwZSI6ImF1dGhSZWNvcmQifQ.0d_0EO6kfn9ijZIQWAqgRi8Bo1z7MKcg1LQpXhQsEPk",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"test"},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnFileDownloadRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -190,3 +393,82 @@ func TestFileDownload(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentThumbsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, err := tests.NewTestApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
// create a dummy file field collection
|
||||
demo1, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileField := demo1.Schema.GetFieldByName("file_one")
|
||||
fileField.Options = &schema.FileOptions{
|
||||
Protected: false,
|
||||
MaxSelect: 1,
|
||||
MaxSize: 999999,
|
||||
// new thumbs
|
||||
Thumbs: []string{"111x111", "111x222", "111x333"},
|
||||
}
|
||||
demo1.Schema.AddField(fileField)
|
||||
if err := app.Dao().SaveCollection(demo1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileKey := "wsmn24bux7wo113/al1h9ijdeojtsjy/300_Jsjq7RdBgA.png"
|
||||
|
||||
e, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
urls := []string{
|
||||
"/api/files/" + fileKey + "?thumb=111x111",
|
||||
"/api/files/" + fileKey + "?thumb=111x111", // should still result in single thumb
|
||||
"/api/files/" + fileKey + "?thumb=111x222",
|
||||
"/api/files/" + fileKey + "?thumb=111x333",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(urls))
|
||||
|
||||
for _, url := range urls {
|
||||
url := url
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
e.ServeHTTP(recorder, req)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// ensure that all new requested thumbs were created
|
||||
thumbKeys := []string{
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x111_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x222_" + filepath.Base(fileKey),
|
||||
"wsmn24bux7wo113/al1h9ijdeojtsjy/thumbs_300_Jsjq7RdBgA.png/111x333_" + filepath.Base(fileKey),
|
||||
}
|
||||
for _, k := range thumbKeys {
|
||||
if exists, _ := fsys.Exists(k); !exists {
|
||||
t.Fatalf("Missing thumb %q: %v", k, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -12,6 +12,7 @@ func bindHealthApi(app core.App, rg *echo.Group) {
|
||||
api := healthApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/health")
|
||||
subGroup.HEAD("", api.healthCheck)
|
||||
subGroup.GET("", api.healthCheck)
|
||||
}
|
||||
|
||||
@@ -19,12 +20,24 @@ type healthApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
type healthCheckResponse struct {
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
CanBackup bool `json:"canBackup"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// healthCheck returns a 200 OK response if the server is healthy.
|
||||
func (api *healthApi) healthCheck(c echo.Context) error {
|
||||
payload := map[string]any{
|
||||
"code": http.StatusOK,
|
||||
"message": "API is healthy.",
|
||||
if c.Request().Method == http.MethodHead {
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, payload)
|
||||
resp := new(healthCheckResponse)
|
||||
resp.Code = http.StatusOK
|
||||
resp.Message = "API is healthy."
|
||||
resp.Data.CanBackup = !api.app.Store().Has(core.StoreKeyActiveBackup)
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
+11
-1
@@ -8,14 +8,24 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "health status returns 200",
|
||||
Name: "HEAD health status",
|
||||
Method: http.MethodHead,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
},
|
||||
{
|
||||
Name: "GET health status",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/health",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"code":200`,
|
||||
`"data":{`,
|
||||
`"canBackup":true`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+18
-18
@@ -15,27 +15,27 @@ func bindLogsApi(app core.App, rg *echo.Group) {
|
||||
api := logsApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/logs", RequireAdminAuth())
|
||||
subGroup.GET("/requests", api.requestsList)
|
||||
subGroup.GET("/requests/stats", api.requestsStats)
|
||||
subGroup.GET("/requests/:id", api.requestView)
|
||||
subGroup.GET("", api.list)
|
||||
subGroup.GET("/stats", api.stats)
|
||||
subGroup.GET("/:id", api.view)
|
||||
}
|
||||
|
||||
type logsApi struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
var requestFilterFields = []string{
|
||||
var logFilterFields = []string{
|
||||
"rowid", "id", "created", "updated",
|
||||
"url", "method", "status", "auth",
|
||||
"remoteIp", "userIp", "referer", "userAgent",
|
||||
"level", "message", "data",
|
||||
`^data\.[\w\.\:]*\w+$`,
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsList(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) list(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
result, err := search.NewProvider(fieldResolver).
|
||||
Query(api.app.LogsDao().RequestQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Request{})
|
||||
Query(api.app.LogsDao().LogQuery()).
|
||||
ParseAndExec(c.QueryParams().Encode(), &[]*models.Log{})
|
||||
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
@@ -44,8 +44,8 @@ func (api *logsApi) requestsList(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(requestFilterFields...)
|
||||
func (api *logsApi) stats(c echo.Context) error {
|
||||
fieldResolver := search.NewSimpleFieldResolver(logFilterFields...)
|
||||
|
||||
filter := c.QueryParam(search.FilterQueryParam)
|
||||
|
||||
@@ -58,24 +58,24 @@ func (api *logsApi) requestsStats(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := api.app.LogsDao().RequestsStats(expr)
|
||||
stats, err := api.app.LogsDao().LogsStats(expr)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to generate requests stats.", err)
|
||||
return NewBadRequestError("Failed to generate logs stats.", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (api *logsApi) requestView(c echo.Context) error {
|
||||
func (api *logsApi) view(c echo.Context) error {
|
||||
id := c.PathParam("id")
|
||||
if id == "" {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
request, err := api.app.LogsDao().FindRequestById(id)
|
||||
if err != nil || request == nil {
|
||||
log, err := api.app.LogsDao().FindLogById(id)
|
||||
if err != nil || log == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, request)
|
||||
return c.JSON(http.StatusOK, log)
|
||||
}
|
||||
|
||||
+27
-21
@@ -8,19 +8,21 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestsList(t *testing.T) {
|
||||
func TestLogsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -30,12 +32,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests",
|
||||
Url: "/api/logs",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -52,12 +54,12 @@ func TestRequestsList(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests?filter=status>200",
|
||||
Url: "/api/logs?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -77,19 +79,21 @@ func TestRequestsList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestView(t *testing.T) {
|
||||
func TestLogView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -99,12 +103,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (nonexisting request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/missing1-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -114,12 +118,12 @@ func TestRequestView(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin (existing request log)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
Url: "/api/logs/873f2133-9f38-44fb-bf82-c8f53b310d91",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -135,19 +139,21 @@ func TestRequestView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
@@ -157,12 +163,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats",
|
||||
Url: "/api/logs/stats",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
@@ -174,12 +180,12 @@ func TestRequestsStats(t *testing.T) {
|
||||
{
|
||||
Name: "authorized as admin + filter",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/logs/requests/stats?filter=status>200",
|
||||
Url: "/api/logs/stats?filter=data.status>200",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
if err := tests.MockRequestLogsData(app); err != nil {
|
||||
if err := tests.MockLogsData(app); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
|
||||
+112
-78
@@ -2,9 +2,10 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +16,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
ContextAdminKey string = "admin"
|
||||
ContextAuthRecordKey string = "authRecord"
|
||||
ContextCollectionKey string = "collection"
|
||||
ContextExecStartKey string = "execStart"
|
||||
)
|
||||
|
||||
// RequireGuestOnly middleware requires a request to NOT have a valid
|
||||
@@ -129,14 +130,17 @@ func RequireAdminAuth() echo.MiddlewareFunc {
|
||||
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
totalAdmins, err := app.Dao().TotalAdmins()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to fetch admins info.", err)
|
||||
}
|
||||
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
|
||||
if admin != nil || totalAdmins == 0 {
|
||||
if totalAdmins == 0 {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -282,84 +286,92 @@ func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.Midd
|
||||
func ActivityLogger(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
err := next(c)
|
||||
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
if err := next(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
status := httpResponse.Status
|
||||
meta := types.JsonMap{}
|
||||
logRequest(app, c, nil)
|
||||
|
||||
if err != nil {
|
||||
switch v := err.(type) {
|
||||
case *echo.HTTPError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.Internal)
|
||||
case *ApiError:
|
||||
status = v.Code
|
||||
meta["errorMessage"] = v.Message
|
||||
meta["errorDetails"] = fmt.Sprint(v.RawData())
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
meta["errorMessage"] = v.Error()
|
||||
}
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
|
||||
model := &models.Request{
|
||||
Url: httpRequest.URL.RequestURI(),
|
||||
Method: strings.ToLower(httpRequest.Method),
|
||||
Status: status,
|
||||
Auth: requestAuth,
|
||||
UserIp: realUserIp(httpRequest, ip),
|
||||
RemoteIp: ip,
|
||||
Referer: httpRequest.Referer(),
|
||||
UserAgent: httpRequest.UserAgent(),
|
||||
Meta: meta,
|
||||
}
|
||||
// set timestamp fields before firing a new go routine
|
||||
model.RefreshCreated()
|
||||
model.RefreshUpdated()
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if err := app.LogsDao().SaveRequest(model); err != nil && app.IsDebug() {
|
||||
log.Println("Log save failed:", err)
|
||||
}
|
||||
|
||||
// Delete old request logs
|
||||
// ---
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Cache().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
|
||||
if daysDiff > float64(app.Settings().Logs.MaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(now.AddDate(0, 0, -1*app.Settings().Logs.MaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Cache().Set("lastLogsDeletedAt", now)
|
||||
} else if app.IsDebug() {
|
||||
log.Println("Logs delete failed:", deleteErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logRequest(app core.App, c echo.Context, err *ApiError) {
|
||||
// no logs retention
|
||||
if app.Settings().Logs.MaxDays == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := make([]any, 0, 15)
|
||||
|
||||
attrs = append(attrs, slog.String("type", "request"))
|
||||
|
||||
started := cast.ToTime(c.Get(ContextExecStartKey))
|
||||
if !started.IsZero() {
|
||||
attrs = append(attrs, slog.Float64("execTime", float64(time.Since(started))/float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
httpRequest := c.Request()
|
||||
httpResponse := c.Response()
|
||||
method := strings.ToUpper(httpRequest.Method)
|
||||
status := httpResponse.Status
|
||||
requestUri := httpRequest.URL.RequestURI()
|
||||
|
||||
// parse the request error
|
||||
if err != nil {
|
||||
status = err.Code
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("error", err.Message),
|
||||
slog.Any("details", err.RawData()),
|
||||
)
|
||||
}
|
||||
|
||||
requestAuth := models.RequestAuthGuest
|
||||
if c.Get(ContextAuthRecordKey) != nil {
|
||||
requestAuth = models.RequestAuthRecord
|
||||
} else if c.Get(ContextAdminKey) != nil {
|
||||
requestAuth = models.RequestAuthAdmin
|
||||
}
|
||||
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("url", requestUri),
|
||||
slog.String("method", method),
|
||||
slog.Int("status", status),
|
||||
slog.String("auth", requestAuth),
|
||||
slog.String("referer", httpRequest.Referer()),
|
||||
slog.String("userAgent", httpRequest.UserAgent()),
|
||||
)
|
||||
|
||||
if app.Settings().Logs.LogIp {
|
||||
ip, _, _ := net.SplitHostPort(httpRequest.RemoteAddr)
|
||||
attrs = append(
|
||||
attrs,
|
||||
slog.String("userIp", realUserIp(httpRequest, ip)),
|
||||
slog.String("remoteIp", ip),
|
||||
)
|
||||
}
|
||||
|
||||
// don't block on logs write
|
||||
routine.FireAndForget(func() {
|
||||
message := method + " "
|
||||
|
||||
if escaped, err := url.PathUnescape(requestUri); err == nil {
|
||||
message += escaped
|
||||
} else {
|
||||
message += requestUri
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.Logger().Error(message, attrs...)
|
||||
} else {
|
||||
app.Logger().Info(message, attrs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Returns the "real" user IP from common proxy headers (or fallbackIp if none is found).
|
||||
//
|
||||
// The returned IP value shouldn't be trusted if not behind a trusted reverse proxy!
|
||||
@@ -368,15 +380,19 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("Fly-Client-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
if ipsList := r.Header.Get("X-Forwarded-For"); ipsList != "" {
|
||||
// extract the first non-empty leftmost-ish ip
|
||||
ips := strings.Split(ipsList, ",")
|
||||
// extract the rightmost ip
|
||||
for i := len(ips) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(ips[i])
|
||||
for _, ip := range ips {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
@@ -385,3 +401,21 @@ func realUserIp(r *http.Request, fallbackIp string) string {
|
||||
|
||||
return fallbackIp
|
||||
}
|
||||
|
||||
// @todo consider removing as this may no longer be needed due to the custom rest.MultiBinder.
|
||||
//
|
||||
// eagerRequestInfoCache ensures that the request data is cached in the request
|
||||
// context to allow reading for example the json request body data more than once.
|
||||
func eagerRequestInfoCache(app core.App) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
switch c.Request().Method {
|
||||
// currently we are eagerly caching only the requests with body
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
RequestInfo(c)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRequireGuestOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "valid record token",
|
||||
@@ -104,6 +106,8 @@ func TestRequireGuestOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -242,6 +246,8 @@ func TestRequireRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -358,6 +364,8 @@ func TestRequireSameContextRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -452,6 +460,8 @@ func TestRequireAdminAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest (while having at least 1 existing admin)",
|
||||
@@ -571,6 +581,8 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -731,6 +743,8 @@ func TestRequireAdminOrRecordAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "guest",
|
||||
@@ -869,6 +883,8 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadCollectionContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
|
||||
+311
-156
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -15,18 +15,20 @@ import (
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// bindRealtimeApi registers the realtime api endpoints.
|
||||
func bindRealtimeApi(app core.App, rg *echo.Group) {
|
||||
api := realtimeApi{app: app}
|
||||
|
||||
subGroup := rg.Group("/realtime", ActivityLogger(app))
|
||||
subGroup := rg.Group("/realtime")
|
||||
subGroup.GET("", api.connect)
|
||||
subGroup.POST("", api.setSubscriptions)
|
||||
subGroup.POST("", api.setSubscriptions, ActivityLogger(app))
|
||||
|
||||
api.bindEvents()
|
||||
}
|
||||
@@ -49,16 +51,19 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := api.app.OnRealtimeDisconnectRequest().Trigger(disconnectEvent); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"OnRealtimeDisconnectRequest error",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}()
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream; charset=UTF-8")
|
||||
c.Response().Header().Set("Content-Type", "text/event-stream")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
c.Response().Header().Set("Connection", "keep-alive")
|
||||
// https://github.com/pocketbase/pocketbase/discussions/480#discussioncomment-3657640
|
||||
// https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering
|
||||
c.Response().Header().Set("X-Accel-Buffering", "no")
|
||||
@@ -66,15 +71,14 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
connectEvent := &core.RealtimeConnectEvent{
|
||||
HttpContext: c,
|
||||
Client: client,
|
||||
IdleTimeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeConnectRequest().Trigger(connectEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if api.app.IsDebug() {
|
||||
log.Printf("Realtime connection established: %s\n", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug("Realtime connection established.", slog.String("clientId", client.Id()))
|
||||
|
||||
// signalize established connection (aka. fire "connect" message)
|
||||
connectMsgEvent := &core.RealtimeMessageEvent{
|
||||
@@ -82,30 +86,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
Client: client,
|
||||
Message: &subscriptions.Message{
|
||||
Name: "PB_CONNECT",
|
||||
Data: `{"clientId":"` + client.Id() + `"}`,
|
||||
Data: []byte(`{"clientId":"` + client.Id() + `"}`),
|
||||
},
|
||||
}
|
||||
connectMsgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(connectMsgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(e)
|
||||
})
|
||||
if connectMsgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver PB_CONNECT):", client.Id(), connectMsgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver PB_CONNECT)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", connectMsgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(connectMsgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend PB_CONNECT error:", err)
|
||||
}
|
||||
|
||||
// start an idle timer to keep track of inactive/forgotten connections
|
||||
idleDuration := 5 * time.Minute
|
||||
idleTimer := time.NewTimer(idleDuration)
|
||||
idleTimeout := connectEvent.IdleTimeout
|
||||
idleTimer := time.NewTimer(idleTimeout)
|
||||
defer idleTimer.Stop()
|
||||
|
||||
for {
|
||||
@@ -115,9 +120,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
case msg, ok := <-client.Channel():
|
||||
if !ok {
|
||||
// channel is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (closed channel):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (closed channel)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -128,30 +134,31 @@ func (api *realtimeApi) connect(c echo.Context) error {
|
||||
}
|
||||
msgErr := api.app.OnRealtimeBeforeMessageSend().Trigger(msgEvent, func(e *core.RealtimeMessageEvent) error {
|
||||
w := e.HttpContext.Response()
|
||||
fmt.Fprint(w, "id:"+e.Client.Id()+"\n")
|
||||
fmt.Fprint(w, "event:"+e.Message.Name+"\n")
|
||||
fmt.Fprint(w, "data:"+e.Message.Data+"\n\n")
|
||||
w.Write([]byte("id:" + e.Client.Id() + "\n"))
|
||||
w.Write([]byte("event:" + e.Message.Name + "\n"))
|
||||
w.Write([]byte("data:"))
|
||||
w.Write(e.Message.Data)
|
||||
w.Write([]byte("\n\n"))
|
||||
w.Flush()
|
||||
return nil
|
||||
return api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent)
|
||||
})
|
||||
if msgErr != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (failed to deliver message):", client.Id(), msgErr)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (failed to deliver message)",
|
||||
slog.String("clientId", client.Id()),
|
||||
slog.String("error", msgErr.Error()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := api.app.OnRealtimeAfterMessageSend().Trigger(msgEvent); err != nil && api.app.IsDebug() {
|
||||
log.Println("OnRealtimeAfterMessageSend error:", err)
|
||||
}
|
||||
|
||||
idleTimer.Stop()
|
||||
idleTimer.Reset(idleDuration)
|
||||
idleTimer.Reset(idleTimeout)
|
||||
case <-c.Request().Context().Done():
|
||||
// connection is closed
|
||||
if api.app.IsDebug() {
|
||||
log.Println("Realtime connection closed (cancelled request):", client.Id())
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Realtime connection closed (cancelled request)",
|
||||
slog.String("clientId", client.Id()),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -190,7 +197,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
Subscriptions: form.Subscriptions,
|
||||
}
|
||||
|
||||
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
return api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
// update auth state
|
||||
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
|
||||
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
|
||||
@@ -201,21 +208,29 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
|
||||
// subscribe to the new subscriptions
|
||||
e.Client.Subscribe(e.Subscriptions...)
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
api.app.Logger().Debug(
|
||||
"Realtime subscriptions updated.",
|
||||
slog.String("clientId", e.Client.Id()),
|
||||
slog.Any("subscriptions", e.Subscriptions),
|
||||
)
|
||||
|
||||
return api.app.OnRealtimeAfterSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRealtimeAfterSubscribeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
|
||||
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
|
||||
if clientModel != nil &&
|
||||
clientModel.TableName() == newModel.TableName() &&
|
||||
clientModel.GetId() == newModel.GetId() {
|
||||
client.Set(contextKey, newModel)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +242,9 @@ func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel model
|
||||
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
|
||||
for _, client := range api.app.SubscriptionsBroker().Clients() {
|
||||
clientModel, _ := client.Get(contextKey).(models.Model)
|
||||
if clientModel != nil && clientModel.GetId() == model.GetId() {
|
||||
if clientModel != nil &&
|
||||
clientModel.TableName() == model.TableName() &&
|
||||
clientModel.GetId() == model.GetId() {
|
||||
api.app.SubscriptionsBroker().Unregister(client.Id())
|
||||
}
|
||||
}
|
||||
@@ -238,7 +255,7 @@ func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model mo
|
||||
func (api *realtimeApi) bindEvents() {
|
||||
// update the clients that has admin or auth record association
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
if record := api.resolveRecord(e.Model); record != nil && record.Collection().IsAuth() {
|
||||
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
|
||||
}
|
||||
|
||||
@@ -251,8 +268,8 @@ func (api *realtimeApi) bindEvents() {
|
||||
|
||||
// remove the client(s) associated to the deleted admin or auth record
|
||||
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
|
||||
if collection := api.resolveRecordCollection(e.Model); collection != nil && collection.IsAuth() {
|
||||
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, e.Model)
|
||||
}
|
||||
|
||||
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
|
||||
@@ -263,84 +280,98 @@ func (api *realtimeApi) bindEvents() {
|
||||
})
|
||||
|
||||
api.app.OnModelAfterCreate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if err := api.broadcastRecord("create", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("create", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record create",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if err := api.broadcastRecord("update", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("update", record, false); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record update",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelBeforeDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record, ok := e.Model.(*models.Record); ok {
|
||||
if err := api.broadcastRecord("delete", record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastRecord("delete", record, true); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to dry cache record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
api.app.OnModelAfterDelete().Add(func(e *core.ModelEvent) error {
|
||||
if record := api.resolveRecord(e.Model); record != nil {
|
||||
if err := api.broadcastDryCachedRecord("delete", record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to broadcast record delete",
|
||||
slog.String("id", record.Id),
|
||||
slog.String("collectionName", record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *models.Record, accessRule *string) bool {
|
||||
admin, _ := client.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
// admins can access everything
|
||||
return true
|
||||
// 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 (api *realtimeApi) resolveRecord(model models.Model) (record *models.Record) {
|
||||
record, _ = model.(*models.Record)
|
||||
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
if record == nil && !strings.HasPrefix(model.TableName(), "_") {
|
||||
record, _ = api.app.Dao().FindRecordById(model.TableName(), model.GetId())
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if *accessRule == "" {
|
||||
return nil // empty public rule
|
||||
}
|
||||
|
||||
// emulate request data
|
||||
requestData := &models.RequestData{
|
||||
Method: "GET",
|
||||
}
|
||||
requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolver.UpdateQuery(q)
|
||||
q.AndWhere(expr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
if err == nil && foundRecord != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return record
|
||||
}
|
||||
|
||||
// resolveRecordCollection 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 (api *realtimeApi) resolveRecordCollection(model models.Model) (collection *models.Collection) {
|
||||
if record, ok := model.(*models.Record); ok {
|
||||
collection = record.Collection()
|
||||
} else if !strings.HasPrefix(model.TableName(), "_") {
|
||||
// check if it is custom Record model struct (ignore "private" tables)
|
||||
collection, _ = api.app.Dao().FindCollectionByNameOrId(model.TableName())
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
// recordData represents the broadcasted record subscrition message data.
|
||||
type recordData struct {
|
||||
Action string `json:"action"`
|
||||
Record *models.Record `json:"record"`
|
||||
Record any `json:"record"` /* map or models.Record */
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record) error {
|
||||
func (api *realtimeApi) broadcastRecord(action string, record *models.Record, dryCache bool) error {
|
||||
collection := record.Collection()
|
||||
if collection == nil {
|
||||
return errors.New("Record collection not set.")
|
||||
return errors.New("[broadcastRecord] Record collection not set.")
|
||||
}
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
@@ -348,77 +379,159 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
|
||||
return nil // no subscribers
|
||||
}
|
||||
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know if the clients have permissions to view them
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
subscriptionRuleMap := map[string]*string{
|
||||
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Id + "/" + cleanRecord.Id): collection.ViewRule,
|
||||
(collection.Name + "/*"): collection.ListRule,
|
||||
(collection.Id + "/*"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
collection.Name: collection.ListRule,
|
||||
collection.Id: collection.ListRule,
|
||||
(collection.Name + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(collection.Id + "/" + record.Id + "?"): collection.ViewRule,
|
||||
(collection.Name + "/*?"): collection.ListRule,
|
||||
(collection.Id + "/*?"): collection.ListRule,
|
||||
// @deprecated: the same as the wildcard topic but kept for backward compatibility
|
||||
(collection.Name + "?"): collection.ListRule,
|
||||
(collection.Id + "?"): collection.ListRule,
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
encodedData := string(dataBytes)
|
||||
dryCacheKey := action + "/" + record.Id
|
||||
|
||||
for _, client := range clients {
|
||||
client := client
|
||||
|
||||
for subscription, rule := range subscriptionRuleMap {
|
||||
if !client.HasSubscription(subscription) {
|
||||
// note: not executed concurrently to avoid races and to ensure
|
||||
// that the access checks are applied for the current record db state
|
||||
for prefix, rule := range subscriptionRuleMap {
|
||||
subs := client.Subscriptions(prefix)
|
||||
if len(subs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !api.canAccessRecord(client, data.Record, rule) {
|
||||
continue
|
||||
}
|
||||
for sub, options := range subs {
|
||||
// create a clean record copy without expand and unknown fields
|
||||
// because we don't know yet which exact fields the client subscription has permissions to access
|
||||
cleanRecord := record.CleanCopy()
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: subscription,
|
||||
Data: encodedData,
|
||||
}
|
||||
// mock request data
|
||||
requestInfo := &models.RequestInfo{
|
||||
Context: models.RequestInfoContextRealtime,
|
||||
Method: "GET",
|
||||
Query: options.Query,
|
||||
Headers: options.Headers,
|
||||
}
|
||||
requestInfo.Admin, _ = client.Get(ContextAdminKey).(*models.Admin)
|
||||
requestInfo.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
|
||||
|
||||
// ignore the auth record email visibility checks for
|
||||
// auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == data.Record.Id ||
|
||||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
|
||||
data.Record.IgnoreEmailVisibility(true) // ignore
|
||||
if newData, err := json.Marshal(data); err == nil {
|
||||
msg.Data = string(newData)
|
||||
if !api.canAccessRecord(cleanRecord, requestInfo, rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
rawExpand := cast.ToString(options.Query[expandQueryParam])
|
||||
if rawExpand != "" {
|
||||
expandErrs := api.app.Dao().ExpandRecord(cleanRecord, strings.Split(rawExpand, ","), expandFetch(api.app.Dao(), requestInfo))
|
||||
if len(expandErrs) > 0 {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] expand errors",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("expand", rawExpand),
|
||||
slog.Any("errors", expandErrs),
|
||||
)
|
||||
}
|
||||
data.Record.IgnoreEmailVisibility(false) // restore
|
||||
}
|
||||
|
||||
// ignore the auth record email visibility checks
|
||||
// for auth owner, admin or manager
|
||||
if collection.IsAuth() {
|
||||
authId := extractAuthIdFromGetter(client)
|
||||
if authId == cleanRecord.Id {
|
||||
if api.canAccessRecord(cleanRecord, requestInfo, collection.AuthOptions().ManageRule) {
|
||||
cleanRecord.IgnoreEmailVisibility(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := &recordData{
|
||||
Action: action,
|
||||
Record: cleanRecord,
|
||||
}
|
||||
|
||||
// check fields
|
||||
rawFields := cast.ToString(options.Query[fieldsQueryParam])
|
||||
if rawFields != "" {
|
||||
decoded, err := rest.PickFields(cleanRecord, rawFields)
|
||||
if err == nil {
|
||||
data.Record = decoded
|
||||
} else {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] pick fields error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("sub", sub),
|
||||
slog.String("fields", rawFields),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"[broadcastRecord] data marshal error",
|
||||
slog.String("id", cleanRecord.Id),
|
||||
slog.String("collectionName", cleanRecord.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: sub,
|
||||
Data: dataBytes,
|
||||
}
|
||||
|
||||
if dryCache {
|
||||
messages, ok := client.Get(dryCacheKey).([]subscriptions.Message)
|
||||
if !ok {
|
||||
messages = []subscriptions.Message{msg}
|
||||
} else {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
client.Set(dryCacheKey, messages)
|
||||
} else {
|
||||
routine.FireAndForget(func() {
|
||||
client.Send(msg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
if !client.IsDiscarded() {
|
||||
client.Channel() <- msg
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// broadcastDryCachedRecord broadcasts all cached record related messages.
|
||||
func (api *realtimeApi) broadcastDryCachedRecord(action string, record *models.Record) error {
|
||||
key := action + "/" + record.Id
|
||||
|
||||
clients := api.app.SubscriptionsBroker().Clients()
|
||||
|
||||
for _, client := range clients {
|
||||
messages, ok := client.Get(key).([]subscriptions.Message)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
client.Unset(key)
|
||||
|
||||
client := client
|
||||
|
||||
routine.FireAndForget(func() {
|
||||
for _, msg := range messages {
|
||||
client.Send(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type getter interface {
|
||||
Get(string) any
|
||||
}
|
||||
@@ -436,3 +549,45 @@ func extractAuthIdFromGetter(val getter) string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// canAccessRecord checks if the subscription client has access to the specified record model.
|
||||
func (api *realtimeApi) canAccessRecord(
|
||||
record *models.Record,
|
||||
requestInfo *models.RequestInfo,
|
||||
accessRule *string,
|
||||
) bool {
|
||||
// check the access rule
|
||||
// ---
|
||||
if ok, _ := api.app.Dao().CanAccessRecord(record, requestInfo, accessRule); !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// check the subscription client-side filter (if any)
|
||||
// ---
|
||||
filter := cast.ToString(requestInfo.Query[search.FilterQueryParam])
|
||||
if filter == "" {
|
||||
return true // no further checks needed
|
||||
}
|
||||
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestInfo, false)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
resolver.UpdateQuery(q)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
+123
-13
@@ -5,10 +5,13 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
@@ -20,6 +23,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`id:`,
|
||||
@@ -32,7 +36,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
@@ -42,6 +46,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "PB_CONNECT interrupt",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
@@ -56,7 +61,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
@@ -66,11 +71,11 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
Name: "Skipping/ignoring messages",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/realtime",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ExpectedStatus: 200,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRealtimeConnectRequest": 1,
|
||||
"OnRealtimeBeforeMessageSend": 1,
|
||||
"OnRealtimeAfterMessageSend": 1,
|
||||
"OnRealtimeDisconnectRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
@@ -78,7 +83,7 @@ func TestRealtimeConnect(t *testing.T) {
|
||||
return hook.StopPropagation
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if len(app.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Errorf("Expected the subscribers to be removed after connection close, found %d", len(app.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
@@ -123,7 +128,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if len(client.Subscriptions()) != 0 {
|
||||
t.Errorf("Expected no subscriptions, got %v", client.Subscriptions())
|
||||
}
|
||||
@@ -144,7 +149,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
client.Subscribe("test0")
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
expectedSubs := []string{"test1", "test2"}
|
||||
if len(expectedSubs) != len(client.Subscriptions()) {
|
||||
t.Errorf("Expected subscriptions %v, got %v", expectedSubs, client.Subscriptions())
|
||||
@@ -174,7 +179,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
admin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if admin == nil {
|
||||
t.Errorf("Expected admin auth model, got nil")
|
||||
@@ -198,7 +203,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
@@ -223,7 +228,7 @@ func TestRealtimeSubscribe(t *testing.T) {
|
||||
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if authRecord == nil {
|
||||
t.Errorf("Expected auth record model, got nil")
|
||||
@@ -253,7 +258,10 @@ func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = authRecord
|
||||
testApp.OnModelAfterDelete().Trigger(e)
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
@@ -282,7 +290,10 @@ func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
|
||||
}
|
||||
authRecord2.SetEmail("new@example.com")
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = authRecord2
|
||||
testApp.OnModelAfterUpdate().Trigger(e)
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != authRecord2.Email() {
|
||||
@@ -305,7 +316,10 @@ func TestRealtimeAdminDeleteEvent(t *testing.T) {
|
||||
client.Set(apis.ContextAdminKey, admin)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = admin
|
||||
testApp.OnModelAfterDelete().Trigger(e)
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
@@ -334,10 +348,106 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
|
||||
}
|
||||
admin2.Email = "new@example.com"
|
||||
|
||||
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: admin2})
|
||||
e := new(core.ModelEvent)
|
||||
e.Dao = testApp.Dao()
|
||||
e.Model = admin2
|
||||
testApp.OnModelAfterUpdate().Trigger(e)
|
||||
|
||||
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
|
||||
if clientAdmin.Email != admin2.Email {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom auth record model struct
|
||||
// -------------------------------------------------------------------
|
||||
var _ models.Model = (*CustomUser)(nil)
|
||||
|
||||
type CustomUser struct {
|
||||
models.BaseModel
|
||||
|
||||
Email string `db:"email" json:"email"`
|
||||
}
|
||||
|
||||
func (m *CustomUser) TableName() string {
|
||||
return "users" // the name of your collection
|
||||
}
|
||||
|
||||
func findCustomUserByEmail(dao *daos.Dao, email string) (*CustomUser, error) {
|
||||
model := &CustomUser{}
|
||||
|
||||
err := dao.ModelQuery(model).
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func TestRealtimeCustomAuthModelDeleteEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// delete the custom user (should unset the client auth record)
|
||||
if err := testApp.Dao().Delete(customUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
|
||||
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeCustomAuthModelUpdateEvent(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
apis.InitApi(testApp)
|
||||
|
||||
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := subscriptions.NewDefaultClient()
|
||||
client.Set(apis.ContextAuthRecordKey, authRecord)
|
||||
testApp.SubscriptionsBroker().Register(client)
|
||||
|
||||
// refetch the authRecord as CustomUser
|
||||
customUser, err := findCustomUserByEmail(testApp.Dao(), "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// change its email
|
||||
customUser.Email = "new@example.com"
|
||||
if err := testApp.Dao().Save(customUser); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
|
||||
if clientAuthRecord.Email() != customUser.Email {
|
||||
t.Fatalf("Expected authRecord with email %q, got %q", customUser.Email, clientAuthRecord.Email())
|
||||
}
|
||||
}
|
||||
|
||||
+215
-120
@@ -1,10 +1,12 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@@ -25,12 +28,15 @@ import (
|
||||
func bindRecordAuthApi(app core.App, rg *echo.Group) {
|
||||
api := recordAuthApi{app: app}
|
||||
|
||||
// global oauth2 subscription redirect handler
|
||||
rg.GET("/oauth2-redirect", api.oauth2SubscriptionRedirect)
|
||||
|
||||
// common collection record related routes
|
||||
subGroup := rg.Group(
|
||||
"/collections/:collection",
|
||||
ActivityLogger(app),
|
||||
LoadCollectionContext(app, models.CollectionTypeAuth),
|
||||
)
|
||||
|
||||
subGroup.GET("/auth-methods", api.authMethods)
|
||||
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
|
||||
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2)
|
||||
@@ -60,26 +66,23 @@ func (api *recordAuthApi) authRefresh(c echo.Context) error {
|
||||
event.Collection = record.Collection()
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordBeforeAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return api.app.OnRecordAfterAuthRefreshRequest().Trigger(event, func(e *core.RecordAuthRefreshEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthRefreshRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
State string `json:"state"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
// technically could be omitted if the provider doesn't support PKCE,
|
||||
// but to avoid breaking existing typed clients we'll return them as empty string
|
||||
CodeVerifier string `json:"codeVerifier"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
AuthUrl string `json:"authUrl"`
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
@@ -91,12 +94,14 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
authOptions := collection.AuthOptions()
|
||||
|
||||
result := struct {
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
UsernamePassword bool `json:"usernamePassword"`
|
||||
EmailPassword bool `json:"emailPassword"`
|
||||
AuthProviders []providerInfo `json:"authProviders"`
|
||||
OnlyVerified bool `json:"onlyVerified"`
|
||||
}{
|
||||
UsernamePassword: authOptions.AllowUsernameAuth,
|
||||
EmailPassword: authOptions.AllowEmailAuth,
|
||||
OnlyVerified: authOptions.OnlyVerified,
|
||||
AuthProviders: []providerInfo{},
|
||||
}
|
||||
|
||||
@@ -112,37 +117,61 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
|
||||
|
||||
provider, err := auth.NewProviderByName(name)
|
||||
if err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug("Missing or invalid provider name", slog.String("name", name))
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
if api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
api.app.Logger().Debug(
|
||||
"Failed to setup provider",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
continue // skip provider
|
||||
}
|
||||
|
||||
state := security.RandomString(30)
|
||||
codeVerifier := security.RandomString(43)
|
||||
codeChallenge := security.S256Challenge(codeVerifier)
|
||||
codeChallengeMethod := "S256"
|
||||
result.AuthProviders = append(result.AuthProviders, providerInfo{
|
||||
Name: name,
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthUrl: provider.BuildAuthUrl(
|
||||
state,
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
|
||||
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
|
||||
})
|
||||
info := providerInfo{
|
||||
Name: name,
|
||||
DisplayName: provider.DisplayName(),
|
||||
State: security.RandomString(30),
|
||||
}
|
||||
|
||||
if info.DisplayName == "" {
|
||||
info.DisplayName = name
|
||||
}
|
||||
|
||||
urlOpts := []oauth2.AuthCodeOption{}
|
||||
|
||||
// custom providers url options
|
||||
switch name {
|
||||
case auth.NameApple:
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
|
||||
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
|
||||
}
|
||||
|
||||
if provider.PKCE() {
|
||||
info.CodeVerifier = security.RandomString(43)
|
||||
info.CodeChallenge = security.S256Challenge(info.CodeVerifier)
|
||||
info.CodeChallengeMethod = "S256"
|
||||
urlOpts = append(urlOpts,
|
||||
oauth2.SetAuthURLParam("code_challenge", info.CodeChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", info.CodeChallengeMethod),
|
||||
)
|
||||
}
|
||||
|
||||
info.AuthUrl = provider.BuildAuthUrl(
|
||||
info.State,
|
||||
urlOpts...,
|
||||
) + "&redirect_uri=" // empty redirect_uri so that users can append their redirect url
|
||||
|
||||
result.AuthProviders = append(result.AuthProviders, info)
|
||||
}
|
||||
|
||||
// sort providers
|
||||
sort.SliceStable(result.AuthProviders, func(i, j int) bool {
|
||||
return result.AuthProviders[i].Name < result.AuthProviders[j].Name
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -168,10 +197,19 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
|
||||
}
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
event.ProviderName = form.Provider
|
||||
|
||||
form.SetBeforeNewRecordCreateFunc(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
|
||||
return createForm.DrySubmit(func(txDao *daos.Dao) error {
|
||||
requestData := RequestData(c)
|
||||
requestData.Data = form.CreateData
|
||||
event.IsNewRecord = true
|
||||
|
||||
// clone the current request data and assign the form create data as its body data
|
||||
requestInfo := *RequestInfo(c)
|
||||
requestInfo.Context = models.RequestInfoContextOAuth2
|
||||
requestInfo.Data = form.CreateData
|
||||
|
||||
createRuleFunc := func(q *dbx.SelectQuery) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
@@ -184,7 +222,7 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
}
|
||||
|
||||
if *collection.CreateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(txDao, collection, &requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -204,14 +242,12 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
})
|
||||
})
|
||||
|
||||
event := new(core.RecordAuthWithOAuth2Event)
|
||||
event.HttpContext = c
|
||||
event.Collection = collection
|
||||
|
||||
_, _, submitErr := form.Submit(func(next forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData]) forms.InterceptorNextFunc[*forms.RecordOAuth2LoginData] {
|
||||
return func(data *forms.RecordOAuth2LoginData) error {
|
||||
event.Record = data.Record
|
||||
event.OAuth2User = data.OAuth2User
|
||||
event.ProviderClient = data.ProviderClient
|
||||
event.IsNewRecord = data.Record == nil
|
||||
|
||||
return api.app.OnRecordBeforeAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
data.Record = e.Record
|
||||
@@ -224,17 +260,21 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
|
||||
e.Record = data.Record
|
||||
e.OAuth2User = data.OAuth2User
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, e.OAuth2User)
|
||||
meta := struct {
|
||||
*auth.AuthUser
|
||||
IsNew bool `json:"isNew"`
|
||||
}{
|
||||
AuthUser: e.OAuth2User,
|
||||
IsNew: event.IsNewRecord,
|
||||
}
|
||||
|
||||
return api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2Event) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, meta)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithOAuth2Request().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -264,17 +304,13 @@ func (api *recordAuthApi) authWithPassword(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to authenticate.", err)
|
||||
}
|
||||
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
return api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event, func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return RecordAuthResponse(api.app, e.HttpContext, e.Record, nil)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterAuthWithPasswordRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -309,30 +345,32 @@ func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
|
||||
return api.app.OnRecordBeforeRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send password reset email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event, func(e *core.RecordRequestPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against emails enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
@@ -359,17 +397,17 @@ func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to set new password.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event, func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmPasswordResetRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -399,30 +437,32 @@ func (api *recordAuthApi) requestVerification(c echo.Context) error {
|
||||
return api.app.OnRecordBeforeRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
// run in background because we don't need to show the result to the client
|
||||
routine.FireAndForget(func() {
|
||||
if err := next(e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := next(e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to send verification email",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestVerificationRequest().Trigger(event, func(e *core.RecordRequestVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterRequestVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
} else if api.app.IsDebug() {
|
||||
log.Println(submitErr)
|
||||
}
|
||||
|
||||
// don't return the response error to prevent emails enumeration
|
||||
// eagerly write 204 response and skip submit errors
|
||||
// as a measure against users enumeration
|
||||
if !c.Response().Committed {
|
||||
c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
@@ -449,17 +489,17 @@ func (api *recordAuthApi) confirmVerification(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event, func(e *core.RecordConfirmVerificationEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmVerificationRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -484,23 +524,23 @@ func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(record *models.Record) error {
|
||||
return api.app.OnRecordBeforeRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if err := next(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to request email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event, func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
api.app.OnRecordAfterRequestEmailChangeRequest().Trigger(event)
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
@@ -527,17 +567,17 @@ func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to confirm email change.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event, func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterConfirmEmailChangeRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
@@ -601,17 +641,72 @@ func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
|
||||
event.Record = record
|
||||
event.ExternalAuth = externalAuth
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
|
||||
return NewBadRequestError("Cannot unlink the external auth provider.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
return api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if handlerErr == nil {
|
||||
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
oauth2SubscriptionTopic string = "@oauth2"
|
||||
oauth2RedirectFailurePath string = "../_/#/auth/oauth2-redirect-failure"
|
||||
oauth2RedirectSuccessPath string = "../_/#/auth/oauth2-redirect-success"
|
||||
)
|
||||
|
||||
type oauth2EventMessage struct {
|
||||
State string `json:"state"`
|
||||
Code string `json:"code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (api *recordAuthApi) oauth2SubscriptionRedirect(c echo.Context) error {
|
||||
state := c.QueryParam("state")
|
||||
if state == "" {
|
||||
api.app.Logger().Debug("Missing OAuth2 state parameter")
|
||||
return c.Redirect(http.StatusTemporaryRedirect, oauth2RedirectFailurePath)
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
client, err := api.app.SubscriptionsBroker().ClientById(state)
|
||||
if err != nil || client.IsDiscarded() || !client.HasSubscription(oauth2SubscriptionTopic) {
|
||||
api.app.Logger().Debug("Missing or invalid OAuth2 subscription client", "error", err, "clientId", state)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, oauth2RedirectFailurePath)
|
||||
}
|
||||
defer client.Unsubscribe(oauth2SubscriptionTopic)
|
||||
|
||||
data := oauth2EventMessage{
|
||||
State: state,
|
||||
Code: c.QueryParam("code"),
|
||||
Error: c.QueryParam("error"),
|
||||
}
|
||||
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
api.app.Logger().Debug("Failed to marshalize OAuth2 redirect data", "error", err)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, oauth2RedirectFailurePath)
|
||||
}
|
||||
|
||||
msg := subscriptions.Message{
|
||||
Name: oauth2SubscriptionTopic,
|
||||
Data: encodedData,
|
||||
}
|
||||
|
||||
client.Send(msg)
|
||||
|
||||
if data.Error != "" || data.Code == "" {
|
||||
api.app.Logger().Debug("Failed OAuth2 redirect due to an error or missing code parameter", "error", data.Error, "clientId", data.State)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, oauth2RedirectFailurePath)
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusTemporaryRedirect, oauth2RedirectSuccessPath)
|
||||
}
|
||||
|
||||
+578
-13
@@ -1,18 +1,24 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordAuthMethodsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -36,6 +42,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":true`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":false`,
|
||||
`"authProviders":[{`,
|
||||
`"name":"gitlab"`,
|
||||
`"state":`,
|
||||
@@ -54,6 +61,7 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
ExpectedContent: []string{
|
||||
`"usernamePassword":false`,
|
||||
`"emailPassword":true`,
|
||||
`"onlyVerified":true`,
|
||||
`"authProviders":[]`,
|
||||
},
|
||||
},
|
||||
@@ -65,6 +73,8 @@ func TestRecordAuthMethodsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthWithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "invalid body format",
|
||||
@@ -208,7 +218,7 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid email and valid password in allowed collection",
|
||||
Name: "valid email (unverified) and valid password in allowed collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -221,6 +231,48 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
`"token":"`,
|
||||
`"id":"4q1xlclmfloku33"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":false`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// onlyVerified collection check
|
||||
{
|
||||
Name: "unverified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified user in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test@example.com",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"record":{`,
|
||||
`"token":"`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"email":"test@example.com"`,
|
||||
`"verified":true`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
@@ -278,6 +330,28 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
"OnRecordAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// after hooks error checks
|
||||
{
|
||||
Name: "OnRecordAfterAuthWithPasswordRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-with-password",
|
||||
Body: strings.NewReader(`{
|
||||
"identity":"test2_username",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthWithPasswordRequest().Add(func(e *core.RecordAuthWithPasswordEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthWithPasswordRequest": 1,
|
||||
"OnRecordAfterAuthWithPasswordRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -286,6 +360,8 @@ func TestRecordAuthWithPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -351,6 +427,60 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unverified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im8xeTBkZDBzcGQ3ODZtZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.-JYlrz5DcGzvb0nYx-xqnSFMu9dupyKY7Vg_FUm0OaM",
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "verified auth record in onlyVerified collection",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/clients/auth-refresh",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"token":`,
|
||||
`"record":`,
|
||||
`"id":"gk390qegs4y47wn"`,
|
||||
`"verified":true`,
|
||||
`"email":"test@example.com"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAuthRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterAuthRefreshRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/auth-refresh?expand=rel,missing",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterAuthRefreshRequest().Add(func(e *core.RecordAuthRefreshEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeAuthRefreshRequest": 1,
|
||||
"OnRecordAfterAuthRefreshRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -359,6 +489,8 @@ func TestRecordAuthRefresh(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -444,6 +576,8 @@ func TestRecordAuthRequestPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -492,10 +626,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -512,7 +644,7 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token and data",
|
||||
Name: "valid token and data (unverified user)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
@@ -527,6 +659,155 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatalf("Expected the user to be unverified")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.Dao().FindAuthRecordByToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected the password reset token to be invalidated")
|
||||
}
|
||||
|
||||
user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to be marked as verified")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token and data (unverified user with different email from the one in the token)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatalf("Expected the user to be unverified")
|
||||
}
|
||||
|
||||
// manually change the email to check whether the verified state will be updated
|
||||
user.SetEmail("test_update@example.com")
|
||||
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
|
||||
t.Fatalf("Failed to update user test email")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.Dao().FindAuthRecordByToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected the password reset token to be invalidated")
|
||||
}
|
||||
|
||||
user, err = app.Dao().FindAuthRecordByEmail("users", "test_update@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
if user.Verified() {
|
||||
t.Fatalf("Expected the user to remain unverified")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "valid token and data (verified user)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
ExpectedStatus: 204,
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
user, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
// ensure that the user is already verified
|
||||
user.SetVerified(true)
|
||||
if err := app.Dao().WithoutHooks().SaveRecord(user); err != nil {
|
||||
t.Fatalf("Failed to update user verified state")
|
||||
}
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
user, err := app.Dao().FindAuthRecordByToken(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected the password reset token to be invalidated")
|
||||
}
|
||||
|
||||
user, err = app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch confirm password user: %v", err)
|
||||
}
|
||||
|
||||
if !user.Verified() {
|
||||
t.Fatalf("Expected the user to remain verified")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmPasswordResetRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-password-reset",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmPasswordResetRequest().Add(func(e *core.RecordConfirmPasswordResetEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmPasswordResetRequest": 1,
|
||||
"OnRecordAfterConfirmPasswordResetRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -536,6 +817,8 @@ func TestRecordAuthConfirmPasswordReset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -629,6 +912,8 @@ func TestRecordAuthRequestVerification(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "empty data",
|
||||
@@ -669,10 +954,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "different auth collection",
|
||||
@@ -730,6 +1013,27 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmVerificationRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-verification",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmVerificationRequest().Add(func(e *core.RecordConfirmVerificationEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmVerificationRequest": 1,
|
||||
"OnRecordAfterConfirmVerificationRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -738,6 +1042,8 @@ func TestRecordAuthConfirmVerification(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -813,7 +1119,7 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":`,
|
||||
`"newEmail":{"code":"validation_record_email_exists"`,
|
||||
`"newEmail":{"code":"validation_record_email_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -840,6 +1146,8 @@ func TestRecordAuthRequestEmailChange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "not an auth collection",
|
||||
@@ -930,6 +1238,28 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
`"token":{"code":"validation_token_collection_mismatch"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterConfirmEmailChangeRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/users/confirm-email-change",
|
||||
Body: strings.NewReader(`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJjaGFuZ2VAZXhhbXBsZS5jb20iLCJleHAiOjIyMDg5ODUyNjF9.1sG6cL708pRXXjiHRZhG-in0X5fnttSf5nNcadKoYRs",
|
||||
"password":"1234567890"
|
||||
}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterConfirmEmailChangeRequest().Add(func(e *core.RecordConfirmEmailChangeEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnRecordBeforeConfirmEmailChangeRequest": 1,
|
||||
"OnRecordAfterConfirmEmailChangeRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -938,6 +1268,8 @@ func TestRecordAuthConfirmEmailChange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthListExternalsAuths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -1038,6 +1370,8 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -1081,7 +1415,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1127,7 +1461,7 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1138,6 +1472,237 @@ func TestRecordAuthUnlinkExternalsAuth(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordBeforeUnlinkExternalAuthRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/users/records/4q1xlclmfloku33/external-auths/google",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUnlinkExternalAuthRequest().Add(func(e *core.RecordUnlinkExternalAuthEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterUnlinkExternalAuthRequest": 1,
|
||||
"OnRecordBeforeUnlinkExternalAuthRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordAuthOAuth2Redirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientStubs := make([]map[string]subscriptions.Client, 0, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
c1 := subscriptions.NewDefaultClient()
|
||||
|
||||
c2 := subscriptions.NewDefaultClient()
|
||||
c2.Subscribe("@oauth2")
|
||||
|
||||
c3 := subscriptions.NewDefaultClient()
|
||||
c3.Subscribe("test1", "@oauth2")
|
||||
|
||||
c4 := subscriptions.NewDefaultClient()
|
||||
c4.Subscribe("test1", "test2")
|
||||
|
||||
c5 := subscriptions.NewDefaultClient()
|
||||
c5.Subscribe("@oauth2")
|
||||
c5.Discard()
|
||||
|
||||
clientStubs = append(clientStubs, map[string]subscriptions.Client{
|
||||
"c1": c1,
|
||||
"c2": c2,
|
||||
"c3": c3,
|
||||
"c4": c4,
|
||||
"c5": c5,
|
||||
})
|
||||
}
|
||||
|
||||
checkFailureRedirect := func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
loc := res.Header.Get("Location")
|
||||
if !strings.Contains(loc, "/oauth2-redirect-failure") {
|
||||
t.Fatalf("Expected failure redirect, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
checkSuccessRedirect := func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
loc := res.Header.Get("Location")
|
||||
if !strings.Contains(loc, "/oauth2-redirect-success") {
|
||||
t.Fatalf("Expected success redirect, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
checkClientMessages := func(t *testing.T, 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)
|
||||
}
|
||||
|
||||
if msg.Name != "@oauth2" {
|
||||
t.Fatalf("Expected @oauth2 msg.Name, got %q", msg.Name)
|
||||
}
|
||||
|
||||
for _, txt := range expectedMessages[clientId] {
|
||||
if !strings.Contains(string(msg.Data), txt) {
|
||||
t.Fatalf("Failed to find %q in \n%s", txt, msg.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeTestFunc := func(
|
||||
clients map[string]subscriptions.Client,
|
||||
expectedMessages map[string][]string,
|
||||
) func(*testing.T, *tests.TestApp, *echo.Echo) {
|
||||
return func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
for _, client := range clients {
|
||||
app.SubscriptionsBroker().Register(client)
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
|
||||
// add to the app store so that it can be cancelled manually after test completion
|
||||
app.Store().Set("cancelFunc", cancelFunc)
|
||||
|
||||
go func() {
|
||||
defer cancelFunc()
|
||||
|
||||
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 <-ctx.Done():
|
||||
for _, c := range clients {
|
||||
close(c.Channel())
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "no state query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123",
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[0], nil),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "invalid or missing client",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=missing",
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[1], nil),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "no code query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?state=" + clientStubs[2]["c3"].Id(),
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[2], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[2]["c3"].Id(), `"code":""`},
|
||||
}),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
|
||||
if clientStubs[2]["c3"].HasSubscription("@oauth2") {
|
||||
t.Fatalf("Expected oauth2 subscription to be removed")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "error query param",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?error=example&code=123&state=" + clientStubs[3]["c3"].Id(),
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[3], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[3]["c3"].Id(), `"code":"123"`, `"error":"example"`},
|
||||
}),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
|
||||
if clientStubs[3]["c3"].HasSubscription("@oauth2") {
|
||||
t.Fatalf("Expected oauth2 subscription to be removed")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "discarded client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c5"].Id(),
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[4], nil),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client without @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[4]["c4"].Id(),
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[5], nil),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkFailureRedirect(t, app, res)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "client with @oauth2 subscription",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/oauth2-redirect?code=123&state=" + clientStubs[6]["c3"].Id(),
|
||||
BeforeTestFunc: beforeTestFunc(clientStubs[6], map[string][]string{
|
||||
"c3": {`"state":"` + clientStubs[6]["c3"].Id(), `"code":"123"`},
|
||||
}),
|
||||
ExpectedStatus: http.StatusTemporaryRedirect,
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
app.Store().Get("cancelFunc").(context.CancelFunc)()
|
||||
|
||||
checkSuccessRedirect(t, app, res)
|
||||
|
||||
if clientStubs[6]["c3"].HasSubscription("@oauth2") {
|
||||
t.Fatalf("Expected oauth2 subscription to be removed")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
+89
-93
@@ -2,9 +2,8 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -16,8 +15,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const expandQueryParam = "expand"
|
||||
|
||||
// bindRecordCrudApi registers the record crud api endpoints and
|
||||
// the corresponding handlers.
|
||||
func bindRecordCrudApi(app core.App, rg *echo.Group) {
|
||||
@@ -45,14 +42,14 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
// forbid users and guests to query special filter/sort fields
|
||||
if err := api.checkForForbiddenQueryFields(c); err != nil {
|
||||
if err := checkForAdminOnlyRuleFields(requestInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
@@ -60,15 +57,15 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
fieldsResolver := resolvers.NewRecordFieldResolver(
|
||||
api.app.Dao(),
|
||||
collection,
|
||||
requestData,
|
||||
requestInfo,
|
||||
// hidden fields are searchable only by admins
|
||||
requestData.Admin != nil,
|
||||
requestInfo.Admin != nil,
|
||||
)
|
||||
|
||||
searchProvider := search.NewProvider(fieldsResolver).
|
||||
Query(api.app.Dao().RecordQuery(collection))
|
||||
|
||||
if requestData.Admin == nil && collection.ListRule != nil {
|
||||
if requestInfo.Admin == nil && collection.ListRule != nil {
|
||||
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
|
||||
}
|
||||
|
||||
@@ -76,7 +73,7 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
|
||||
result, err := searchProvider.ParseAndExec(c.QueryParams().Encode(), &records)
|
||||
if err != nil {
|
||||
return NewBadRequestError("Invalid filter parameters.", err)
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
event := new(core.RecordsListEvent)
|
||||
@@ -86,8 +83,12 @@ func (api *recordApi) list(c echo.Context) error {
|
||||
event.Result = result
|
||||
|
||||
return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error {
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil {
|
||||
api.app.Logger().Debug("Failed to enrich list records", slog.String("error", err.Error()))
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Result)
|
||||
@@ -105,16 +106,16 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.ViewRule == nil {
|
||||
if requestInfo.Admin == nil && collection.ViewRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -136,8 +137,17 @@ func (api *recordApi) view(c echo.Context) error {
|
||||
event.Record = record
|
||||
|
||||
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error {
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich view record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
@@ -150,23 +160,23 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewNotFoundError("", "Missing collection context.")
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.CreateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
hasFullManageAccess := requestData.Admin != nil
|
||||
hasFullManageAccess := requestInfo.Admin != nil
|
||||
|
||||
// temporary save the record and check it against the create rule
|
||||
if requestData.Admin == nil && collection.CreateRule != nil {
|
||||
if requestInfo.Admin == nil && collection.CreateRule != nil {
|
||||
testRecord := models.NewRecord(collection)
|
||||
|
||||
// replace modifiers fields so that the resolved value is always
|
||||
// available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
requestData.Data = testRecord.ReplaceModifers(requestData.Data)
|
||||
// available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
requestInfo.Data = testRecord.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
testForm := forms.NewRecordUpsert(api.app, testRecord)
|
||||
@@ -180,7 +190,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return nil // no create rule to resolve
|
||||
}
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -195,7 +205,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("DrySubmit create rule failure: %w", err)
|
||||
}
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
|
||||
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestInfo)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -220,7 +230,7 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// create the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -229,22 +239,25 @@ func (api *recordApi) create(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to create record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich create record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterCreateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) update(c echo.Context) error {
|
||||
@@ -258,26 +271,26 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.UpdateRule == nil {
|
||||
if requestInfo.Admin == nil && collection.UpdateRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
// eager fetch the record so that the modifier field values are replaced
|
||||
// and available when accessing requestData.Data using just the field name
|
||||
if requestData.HasModifierDataKeys() {
|
||||
// and available when accessing requestInfo.Data using just the field name
|
||||
if requestInfo.HasModifierDataKeys() {
|
||||
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
|
||||
if err != nil || record == nil {
|
||||
return NewNotFoundError("", err)
|
||||
}
|
||||
requestData.Data = record.ReplaceModifers(requestData.Data)
|
||||
requestInfo.Data = record.ReplaceModifers(requestInfo.Data)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -295,7 +308,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(api.app, record)
|
||||
form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
|
||||
form.SetFullManageAccess(requestInfo.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestInfo))
|
||||
|
||||
// load request
|
||||
if err := form.LoadRequest(c.Request(), ""); err != nil {
|
||||
@@ -309,7 +322,7 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
event.UploadedFiles = form.FilesToUpload()
|
||||
|
||||
// update the record
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*models.Record]) forms.InterceptorNextFunc[*models.Record] {
|
||||
return func(m *models.Record) error {
|
||||
event.Record = m
|
||||
|
||||
@@ -318,22 +331,25 @@ func (api *recordApi) update(c echo.Context) error {
|
||||
return NewBadRequestError("Failed to update record.", err)
|
||||
}
|
||||
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil {
|
||||
api.app.Logger().Debug(
|
||||
"Failed to enrich update record",
|
||||
slog.String("id", e.Record.Id),
|
||||
slog.String("collectionName", e.Record.Collection().Name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
return api.app.OnRecordAfterUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.Record)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnRecordAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *recordApi) delete(c echo.Context) error {
|
||||
@@ -347,16 +363,16 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
return NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if requestData.Admin == nil && collection.DeleteRule == nil {
|
||||
if requestInfo.Admin == nil && collection.DeleteRule == nil {
|
||||
// only admins can access if the rule is nil
|
||||
return NewForbiddenError("Only admins can perform this action.", nil)
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
|
||||
if requestInfo.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -377,38 +393,18 @@ func (api *recordApi) delete(c echo.Context) error {
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
return api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
// delete the record
|
||||
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
|
||||
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
return api.app.OnRecordAfterDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.NoContent(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
if handlerErr == nil {
|
||||
if err := api.app.OnRecordAfterDeleteRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
|
||||
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
|
||||
if admin != nil {
|
||||
return nil // admins are allowed to query everything
|
||||
}
|
||||
|
||||
decodedQuery := c.QueryParam(search.FilterQueryParam) + c.QueryParam(search.SortQueryParam)
|
||||
forbiddenFields := []string{"@collection.", "@request."}
|
||||
|
||||
for _, field := range forbiddenFields {
|
||||
if strings.Contains(decodedQuery, field) {
|
||||
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+277
-7
@@ -1,6 +1,7 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -10,11 +11,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordCrudList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -41,9 +47,16 @@ func TestRecordCrudList(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only filter/sort (aka. @collection)",
|
||||
Name: "public collection but with admin only filter param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?filter=@collection.demo2.title='test1'",
|
||||
Url: "/api/collections/demo2/records?filter=%40collection.demo2.title='test1'",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "public collection but with admin only sort param (aka. @collection, @request, etc.)",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/demo2/records?sort=@request.auth.title",
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
@@ -460,6 +473,22 @@ func TestRecordCrudList(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view collection with numeric ids",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"page":1`,
|
||||
`"perPage":30`,
|
||||
`"totalPages":1`,
|
||||
`"totalItems":2`,
|
||||
`"items":[{`,
|
||||
`"id":"1"`,
|
||||
`"id":"2"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordsListRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -468,6 +497,8 @@ func TestRecordCrudList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -729,6 +760,16 @@ func TestRecordCrudView(t *testing.T) {
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
{
|
||||
Name: "view record with numeric id",
|
||||
Method: http.MethodGet,
|
||||
Url: "/api/collections/numeric_id_view/records/1",
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"1"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{"OnRecordViewRequest": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -737,6 +778,8 @@ func TestRecordCrudView(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ensureDeletedFiles := func(app *tests.TestApp, collectionId string, recordId string) {
|
||||
storageDir := filepath.Join(app.DataDir(), "storage", collectionId, recordId)
|
||||
|
||||
@@ -836,6 +879,27 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterDeleteRequest error response",
|
||||
Method: http.MethodDelete,
|
||||
Url: "/api/collections/clients/records/o1y0dd0spd786md",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterDeleteRequest().Add(func(e *core.RecordDeleteEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelAfterDelete": 1,
|
||||
"OnModelBeforeDelete": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authenticated record that match the collection delete rule",
|
||||
Method: http.MethodDelete,
|
||||
@@ -854,7 +918,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
ensureDeletedFiles(app, "_pb_users_auth_", "4q1xlclmfloku33")
|
||||
|
||||
// check if all the external auths records were deleted
|
||||
@@ -941,7 +1005,7 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
"OnRecordBeforeDeleteRequest": 1,
|
||||
"OnRecordAfterDeleteRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
recId := "84nmscqy84lsi1t"
|
||||
rec, _ := app.Dao().FindRecordById("demo1", recId, nil)
|
||||
if rec != nil {
|
||||
@@ -959,6 +1023,8 @@ func TestRecordCrudDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "title_test",
|
||||
}, "files")
|
||||
@@ -966,6 +1032,20 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
|
||||
rest.MultipartJsonKey: `{"title": "title_test2", "testPayload": 123}`,
|
||||
}, "files")
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
|
||||
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
|
||||
rest.MultipartJsonKey: `{"title": "title_test3", "testPayload": 123}`,
|
||||
}, "files")
|
||||
if err3 != nil {
|
||||
t.Fatal(err3)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -1173,6 +1253,91 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.data rule",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo3/records",
|
||||
Body: formData2,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": mp2.FormDataContentType(),
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find demo3 collection: %v", err)
|
||||
}
|
||||
collection.CreateRule = types.Pointer("@request.data.testPayload != 123")
|
||||
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
|
||||
t.Fatalf("failed to update demo3 collection create rule: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.data rule",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo3/records",
|
||||
Body: formData3,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": mp3.FormDataContentType(),
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find demo3 collection: %v", err)
|
||||
}
|
||||
collection.CreateRule = types.Pointer("@request.data.testPayload = 123")
|
||||
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
|
||||
t.Fatalf("failed to update demo3 collection create rule: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"`,
|
||||
`"title":"title_test3"`,
|
||||
`"files":["`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unique field error check",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{
|
||||
"title":"test2"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"title":{`,
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterCreateRequest error response",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/collections/demo2/records",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeCreateRequest": 1,
|
||||
"OnRecordAfterCreateRequest": 1,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// ID checks
|
||||
// -----------------------------------------------------------
|
||||
@@ -1502,6 +1667,8 @@ func TestRecordCrudCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordCrudUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "title_test",
|
||||
}, "files")
|
||||
@@ -1509,6 +1676,20 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData2, mp2, err2 := tests.MockMultipartData(map[string]string{
|
||||
rest.MultipartJsonKey: `{"title": "title_test2", "testPayload": 123}`,
|
||||
}, "files")
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
|
||||
formData3, mp3, err3 := tests.MockMultipartData(map[string]string{
|
||||
rest.MultipartJsonKey: `{"title": "title_test3", "testPayload": 123}`,
|
||||
}, "files")
|
||||
if err3 != nil {
|
||||
t.Fatal(err3)
|
||||
}
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "missing collection",
|
||||
@@ -1731,6 +1912,77 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and unsatisfied @request.data rule",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
|
||||
Body: formData2,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": mp2.FormDataContentType(),
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find demo3 collection: %v", err)
|
||||
}
|
||||
collection.UpdateRule = types.Pointer("@request.data.testPayload != 123")
|
||||
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
|
||||
t.Fatalf("failed to update demo3 collection update rule: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "submit via multipart form data with @jsonPayload key and satisfied @request.data rule",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo3/records/mk5fmymtx4wsprk",
|
||||
Body: formData3,
|
||||
RequestHeaders: map[string]string{
|
||||
"Content-Type": mp3.FormDataContentType(),
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find demo3 collection: %v", err)
|
||||
}
|
||||
collection.UpdateRule = types.Pointer("@request.data.testPayload = 123")
|
||||
if err := app.Dao().WithoutHooks().SaveCollection(collection); err != nil {
|
||||
t.Fatalf("failed to update demo3 collection update rule: %v", err)
|
||||
}
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"id":"mk5fmymtx4wsprk"`,
|
||||
`"title":"title_test3"`,
|
||||
`"files":["`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnRecordAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/0yxhwia2amd8gec",
|
||||
Body: strings.NewReader(`{"title":"new"}`),
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "try to change the id of an existing record",
|
||||
Method: http.MethodPatch,
|
||||
@@ -1747,6 +1999,24 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
`"id":{"code":"validation_in_invalid"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unique field error check",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/collections/demo2/records/llvuca81nly1qls",
|
||||
Body: strings.NewReader(`{
|
||||
"title":"test2"
|
||||
}`),
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"title":{`,
|
||||
`"code":"validation_not_unique"`,
|
||||
},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
"OnModelBeforeUpdate": 1,
|
||||
},
|
||||
},
|
||||
|
||||
// check whether if @request.data modifer fields are properly resolved
|
||||
// -----------------------------------------------------------
|
||||
@@ -1913,7 +2183,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("nologin", "phhq3wr65cap535")
|
||||
if !record.ValidatePassword("12345678") {
|
||||
t.Fatal("Password update failed.")
|
||||
@@ -1956,7 +2226,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
if !record.ValidatePassword("12345678") {
|
||||
t.Fatal("Password update failed.")
|
||||
@@ -2018,7 +2288,7 @@ func TestRecordCrudUpdate(t *testing.T) {
|
||||
"OnRecordAfterUpdateRequest": 1,
|
||||
"OnRecordBeforeUpdateRequest": 1,
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
record, _ := app.Dao().FindRecordById("nologin", "dc49k6jgejn40h3")
|
||||
if !record.ValidatePassword("123456789") {
|
||||
t.Fatal("Password update failed.")
|
||||
|
||||
+111
-35
@@ -3,6 +3,7 @@ package apis
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -13,26 +14,49 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tokens"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
)
|
||||
|
||||
const ContextRequestDataKey = "requestData"
|
||||
const ContextRequestInfoKey = "requestInfo"
|
||||
|
||||
// RequestData exports cached common request data fields
|
||||
const expandQueryParam = "expand"
|
||||
const fieldsQueryParam = "fields"
|
||||
|
||||
// Deprecated: Use RequestInfo instead.
|
||||
func RequestData(c echo.Context) *models.RequestInfo {
|
||||
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
|
||||
return RequestInfo(c)
|
||||
}
|
||||
|
||||
// RequestInfo exports cached common request data fields
|
||||
// (query, body, logged auth state, etc.) from the provided context.
|
||||
func RequestData(c echo.Context) *models.RequestData {
|
||||
func RequestInfo(c echo.Context) *models.RequestInfo {
|
||||
// return cached to avoid copying the body multiple times
|
||||
if v := c.Get(ContextRequestDataKey); v != nil {
|
||||
if data, ok := v.(*models.RequestData); ok {
|
||||
if v := c.Get(ContextRequestInfoKey); v != nil {
|
||||
if data, ok := v.(*models.RequestInfo); ok {
|
||||
// refresh auth state
|
||||
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
result := &models.RequestData{
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
result := &models.RequestInfo{
|
||||
Context: models.RequestInfoContextDefault,
|
||||
Method: c.Request().Method,
|
||||
Query: map[string]any{},
|
||||
Data: map[string]any{},
|
||||
Headers: map[string]any{},
|
||||
}
|
||||
|
||||
// extract the first value of all headers and normalizes the keys
|
||||
// ("X-Token" is converted to "x_token")
|
||||
for k, v := range c.Request().Header {
|
||||
if len(v) > 0 {
|
||||
result.Headers[inflector.Snakecase(k)] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
||||
@@ -40,12 +64,24 @@ func RequestData(c echo.Context) *models.RequestData {
|
||||
echo.BindQueryParams(c, &result.Query)
|
||||
rest.BindBody(c, &result.Data)
|
||||
|
||||
c.Set(ContextRequestDataKey, result)
|
||||
c.Set(ContextRequestInfoKey, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record, meta any) error {
|
||||
// RecordAuthResponse writes standardised json record auth response
|
||||
// into the specified request context.
|
||||
func RecordAuthResponse(
|
||||
app core.App,
|
||||
c echo.Context,
|
||||
authRecord *models.Record,
|
||||
meta any,
|
||||
finalizers ...func(token string) error,
|
||||
) error {
|
||||
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
|
||||
return NewForbiddenError("Please verify your email first.", nil)
|
||||
}
|
||||
|
||||
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
||||
if tokenErr != nil {
|
||||
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
||||
@@ -59,6 +95,10 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
event.Meta = meta
|
||||
|
||||
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow always returning the email address of the authenticated account
|
||||
e.Record.IgnoreEmailVisibility(true)
|
||||
|
||||
@@ -66,16 +106,16 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
|
||||
if len(expands) > 0 {
|
||||
// create a copy of the cached request data and adjust it to the current auth record
|
||||
requestData := *RequestData(e.HttpContext)
|
||||
requestData.Admin = nil
|
||||
requestData.AuthRecord = e.Record
|
||||
requestInfo := *RequestInfo(e.HttpContext)
|
||||
requestInfo.Admin = nil
|
||||
requestInfo.AuthRecord = e.Record
|
||||
failed := app.Dao().ExpandRecord(
|
||||
e.Record,
|
||||
expands,
|
||||
expandFetch(app.Dao(), &requestData),
|
||||
expandFetch(app.Dao(), &requestInfo),
|
||||
)
|
||||
if len(failed) > 0 && app.IsDebug() {
|
||||
log.Println("Failed to expand relations: ", failed)
|
||||
if len(failed) > 0 {
|
||||
app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +128,12 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
result["meta"] = e.Meta
|
||||
}
|
||||
|
||||
for _, f := range finalizers {
|
||||
if err := f(e.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, result)
|
||||
})
|
||||
}
|
||||
@@ -95,7 +141,7 @@ func RecordAuthResponse(app core.App, c echo.Context, authRecord *models.Record,
|
||||
// EnrichRecord parses the request context and enrich the provided record:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth record and its expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
// are visible only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error {
|
||||
return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...)
|
||||
}
|
||||
@@ -103,21 +149,23 @@ func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultE
|
||||
// EnrichRecords parses the request context and enriches the provided records:
|
||||
// - expands relations (if defaultExpands and/or ?expand query param is set)
|
||||
// - ensures that the emails of the auth records and their expanded auth relations
|
||||
// are visibe only for the current logged admin, record owner or record with manage access
|
||||
// are visible only for the current logged admin, record owner or record with manage access
|
||||
func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error {
|
||||
requestData := RequestData(c)
|
||||
requestInfo := RequestInfo(c)
|
||||
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil {
|
||||
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
|
||||
return fmt.Errorf("Failed to resolve email visibility: %w", err)
|
||||
}
|
||||
|
||||
expands := defaultExpands
|
||||
expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...)
|
||||
if param := c.QueryParam(expandQueryParam); param != "" {
|
||||
expands = append(expands, strings.Split(param, ",")...)
|
||||
}
|
||||
if len(expands) == 0 {
|
||||
return nil // nothing to expand
|
||||
}
|
||||
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData))
|
||||
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Failed to expand: %v", errs)
|
||||
}
|
||||
@@ -128,11 +176,11 @@ func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defa
|
||||
// expandFetch is the records fetch function that is used to expand related records.
|
||||
func expandFetch(
|
||||
dao *daos.Dao,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) daos.ExpandFetchFunc {
|
||||
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
return nil // admins can access everything
|
||||
}
|
||||
|
||||
@@ -141,7 +189,7 @@ func expandFetch(
|
||||
}
|
||||
|
||||
if *relCollection.ViewRule != "" {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
|
||||
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -154,7 +202,7 @@ func expandFetch(
|
||||
})
|
||||
|
||||
if err == nil && len(records) > 0 {
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
|
||||
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
|
||||
}
|
||||
|
||||
return records, err
|
||||
@@ -168,13 +216,13 @@ func expandFetch(
|
||||
func autoIgnoreAuthRecordsEmailVisibility(
|
||||
dao *daos.Dao,
|
||||
records []*models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) error {
|
||||
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if requestData.Admin != nil {
|
||||
if requestInfo.Admin != nil {
|
||||
for _, rec := range records {
|
||||
rec.IgnoreEmailVisibility(true)
|
||||
}
|
||||
@@ -190,8 +238,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
recordIds[i] = rec.Id
|
||||
}
|
||||
|
||||
if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
|
||||
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
|
||||
}
|
||||
|
||||
authOptions := collection.AuthOptions()
|
||||
@@ -207,7 +255,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
||||
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
||||
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
|
||||
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -236,7 +284,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
|
||||
func hasAuthManageAccess(
|
||||
dao *daos.Dao,
|
||||
record *models.Record,
|
||||
requestData *models.RequestData,
|
||||
requestInfo *models.RequestInfo,
|
||||
) bool {
|
||||
if !record.Collection().IsAuth() {
|
||||
return false
|
||||
@@ -248,12 +296,12 @@ func hasAuthManageAccess(
|
||||
return false // only for admins (manageRule can't be empty)
|
||||
}
|
||||
|
||||
if requestData == nil || requestData.AuthRecord == nil {
|
||||
if requestInfo == nil || requestInfo.AuthRecord == nil {
|
||||
return false // no auth record
|
||||
}
|
||||
|
||||
ruleFunc := func(q *dbx.SelectQuery) error {
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -267,3 +315,31 @@ func hasAuthManageAccess(
|
||||
|
||||
return findErr == nil
|
||||
}
|
||||
|
||||
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
||||
var adminOnlyRuleFields = []string{"@collection.", "@request."}
|
||||
|
||||
// @todo consider moving the rules check to the RecordFieldResolver.
|
||||
//
|
||||
// checkForAdminOnlyRuleFields loosely checks and returns an error if
|
||||
// the provided RequestInfo contains rule fields that only the admin can use.
|
||||
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
|
||||
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
|
||||
return nil // admin or nothing to check
|
||||
}
|
||||
|
||||
for _, param := range ruleQueryParams {
|
||||
v, _ := requestInfo.Query[param].(string)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, field := range adminOnlyRuleFields {
|
||||
if strings.Contains(v, field) {
|
||||
return NewForbiddenError("Only admins can filter by "+field, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRequestData(t *testing.T) {
|
||||
func TestRequestInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
req.Header.Set("X-Token-Test", "123")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
@@ -28,16 +31,22 @@ func TestRequestData(t *testing.T) {
|
||||
dummyAdmin.Id = "id2"
|
||||
c.Set(apis.ContextAdminKey, dummyAdmin)
|
||||
|
||||
result := apis.RequestData(c)
|
||||
result := apis.RequestInfo(c)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected *models.RequestData instance, got nil")
|
||||
t.Fatal("Expected *models.RequestInfo instance, got nil")
|
||||
}
|
||||
|
||||
if result.Method != http.MethodPost {
|
||||
t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method)
|
||||
}
|
||||
|
||||
rawHeaders, _ := json.Marshal(result.Headers)
|
||||
expectedHeaders := `{"content_type":"application/json","x_token_test":"123"}`
|
||||
if v := string(rawHeaders); v != expectedHeaders {
|
||||
t.Fatalf("Expected Query %v, got %v", expectedHeaders, v)
|
||||
}
|
||||
|
||||
rawQuery, _ := json.Marshal(result.Query)
|
||||
expectedQuery := `{"test":"123"}`
|
||||
if v := string(rawQuery); v != expectedQuery {
|
||||
@@ -60,6 +69,8 @@ func TestRequestData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordAuthResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -76,6 +87,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unverifiedAuthRecord, err := app.Dao().FindRecordById("clients", "o1y0dd0spd786md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
@@ -90,6 +106,11 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
record: nonAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record but with unverified email in onlyVerified collection",
|
||||
record: unverifiedAuthRecord,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid auth record - without meta",
|
||||
record: authRecord,
|
||||
@@ -172,6 +193,8 @@ func TestRecordAuthResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnrichRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// ServeConfig defines a configuration struct for apis.Serve().
|
||||
type ServeConfig struct {
|
||||
// ShowStartBanner indicates whether to show or hide the server start console message.
|
||||
ShowStartBanner bool
|
||||
|
||||
// HttpAddr is the TCP address to listen for the HTTP server (eg. `127.0.0.1:80`).
|
||||
HttpAddr string
|
||||
|
||||
// HttpsAddr is the TCP address to listen for the HTTPS server (eg. `127.0.0.1:443`).
|
||||
HttpsAddr string
|
||||
|
||||
// Optional domains list to use when issuing the TLS certificate.
|
||||
//
|
||||
// If not set, the host from the bound server address will be used.
|
||||
//
|
||||
// For convenience, for each "non-www" domain a "www" entry and
|
||||
// redirect will be automatically added.
|
||||
CertificateDomains []string
|
||||
|
||||
// AllowedOrigins is an optional list of CORS origins (default to "*").
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// Serve starts a new app web server.
|
||||
//
|
||||
// NB! The app should be bootstrapped before starting the web server.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// app.Bootstrap()
|
||||
// apis.Serve(app, apis.ServeConfig{
|
||||
// HttpAddr: "127.0.0.1:8080",
|
||||
// ShowStartBanner: false,
|
||||
// })
|
||||
func Serve(app core.App, config ServeConfig) (*http.Server, error) {
|
||||
if len(config.AllowedOrigins) == 0 {
|
||||
config.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
|
||||
router, err := InitApi(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: config.AllowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := config.HttpAddr
|
||||
if config.HttpsAddr != "" {
|
||||
mainAddr = config.HttpsAddr
|
||||
}
|
||||
|
||||
var wwwRedirects []string
|
||||
|
||||
// extract the host names for the certificate host policy
|
||||
hostNames := config.CertificateDomains
|
||||
if len(hostNames) == 0 {
|
||||
host, _, _ := net.SplitHostPort(mainAddr)
|
||||
hostNames = append(hostNames, host)
|
||||
}
|
||||
for _, host := range hostNames {
|
||||
if strings.HasPrefix(host, "www.") {
|
||||
continue // explicitly set www host
|
||||
}
|
||||
|
||||
wwwHost := "www." + host
|
||||
if !list.ExistInSlice(wwwHost, hostNames) {
|
||||
hostNames = append(hostNames, wwwHost)
|
||||
wwwRedirects = append(wwwRedirects, wwwHost)
|
||||
}
|
||||
}
|
||||
|
||||
// implicit www->non-www redirect(s)
|
||||
if len(wwwRedirects) > 0 {
|
||||
router.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
host := c.Request().Host
|
||||
|
||||
if strings.HasPrefix(host, "www.") && list.ExistInSlice(host, wwwRedirects) {
|
||||
return c.Redirect(
|
||||
http.StatusTemporaryRedirect,
|
||||
(c.Scheme() + "://" + host[4:] + c.Request().RequestURI),
|
||||
)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
certManager := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(hostNames...),
|
||||
}
|
||||
|
||||
// base request context used for cancelling long running requests
|
||||
// like the SSE connections
|
||||
baseCtx, cancelBaseCtx := context.WithCancel(context.Background())
|
||||
defer cancelBaseCtx()
|
||||
|
||||
server := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 10 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return baseCtx
|
||||
},
|
||||
}
|
||||
|
||||
serveEvent := &core.ServeEvent{
|
||||
App: app,
|
||||
Router: router,
|
||||
Server: server,
|
||||
CertManager: certManager,
|
||||
}
|
||||
if err := app.OnBeforeServe().Trigger(serveEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, addr),
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// WaitGroup to block until server.ShutDown() returns because Serve and similar methods exit immediately.
|
||||
// Note that the WaitGroup would not do anything if the app.OnTerminate() hook isn't triggered.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// try to gracefully shutdown the server on app termination
|
||||
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
|
||||
cancelBaseCtx()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
wg.Add(1)
|
||||
server.Shutdown(ctx)
|
||||
if e.IsRestart {
|
||||
// wait for execve and other handlers up to 5 seconds before exit
|
||||
time.AfterFunc(5*time.Second, func() {
|
||||
wg.Done()
|
||||
})
|
||||
} else {
|
||||
wg.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// wait for the graceful shutdown to complete before exit
|
||||
defer wg.Wait()
|
||||
|
||||
// ---
|
||||
// @todo consider removing the server return value because it is
|
||||
// not really useful when combined with the blocking serve calls
|
||||
// ---
|
||||
|
||||
// start HTTPS server
|
||||
if config.HttpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if config.HttpAddr != "" {
|
||||
go http.ListenAndServe(config.HttpAddr, certManager.HTTPHandler(nil))
|
||||
}
|
||||
|
||||
return server, server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// OR start HTTP server
|
||||
return server, server.ListenAndServe()
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := runner.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+56
-36
@@ -1,8 +1,6 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -10,7 +8,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// bindSettingsApi registers the settings api endpoints.
|
||||
@@ -22,6 +19,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
|
||||
subGroup.PATCH("", api.set)
|
||||
subGroup.POST("/test/s3", api.testS3)
|
||||
subGroup.POST("/test/email", api.testEmail)
|
||||
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
|
||||
}
|
||||
|
||||
type settingsApi struct {
|
||||
@@ -39,6 +37,10 @@ func (api *settingsApi) list(c echo.Context) error {
|
||||
event.RedactedSettings = settings
|
||||
|
||||
return api.app.OnSettingsListRequest().Trigger(event, func(e *core.SettingsListEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, e.RedactedSettings)
|
||||
})
|
||||
}
|
||||
@@ -56,7 +58,7 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
event.OldSettings = api.app.Settings()
|
||||
|
||||
// update the settings
|
||||
submitErr := form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return form.Submit(func(next forms.InterceptorNextFunc[*settings.Settings]) forms.InterceptorNextFunc[*settings.Settings] {
|
||||
return func(s *settings.Settings) error {
|
||||
event.NewSettings = s
|
||||
|
||||
@@ -65,47 +67,40 @@ func (api *settingsApi) set(c echo.Context) error {
|
||||
return NewBadRequestError("An error occurred while submitting the form.", err)
|
||||
}
|
||||
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
return api.app.OnSettingsAfterUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
|
||||
if e.HttpContext.Response().Committed {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
redactedSettings, err := api.app.Settings().RedactClone()
|
||||
if err != nil {
|
||||
return NewBadRequestError("", err)
|
||||
}
|
||||
|
||||
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if submitErr == nil {
|
||||
if err := api.app.OnSettingsAfterUpdateRequest().Trigger(event); err != nil && api.app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
return submitErr
|
||||
}
|
||||
|
||||
func (api *settingsApi) testS3(c echo.Context) error {
|
||||
if !api.app.Settings().S3.Enabled {
|
||||
return NewBadRequestError("S3 storage is not enabled.", nil)
|
||||
form := forms.NewTestS3Filesystem(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
fs, err := api.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
defer fs.Close()
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
return NewBadRequestError("Failed to test the S3 filesystem.", fErr)
|
||||
}
|
||||
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fs.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return NewBadRequestError(fmt.Sprintf("Failed to delete a test file. Raw error: %v", errs), nil)
|
||||
// mailer error
|
||||
return NewBadRequestError("Failed to test the S3 filesystem. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
@@ -121,8 +116,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
|
||||
// send
|
||||
if err := form.Submit(); err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
// form error
|
||||
return NewBadRequestError("Failed to send the test email.", fErr)
|
||||
}
|
||||
|
||||
@@ -132,3 +127,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
|
||||
form := forms.NewAppleClientSecretCreate(api.app)
|
||||
|
||||
// load request
|
||||
if err := c.Bind(form); err != nil {
|
||||
return NewBadRequestError("An error occurred while loading the submitted data.", err)
|
||||
}
|
||||
|
||||
// generate
|
||||
secret, err := form.Submit()
|
||||
if err != nil {
|
||||
// form error
|
||||
if fErr, ok := err.(validation.Errors); ok {
|
||||
return NewBadRequestError("Invalid client secret data.", fErr)
|
||||
}
|
||||
|
||||
// secret generation error
|
||||
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"secret": secret,
|
||||
})
|
||||
}
|
||||
|
||||
+222
-6
@@ -1,15 +1,25 @@
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestSettingsList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -41,12 +51,15 @@ func TestSettingsList(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -65,6 +78,14 @@ func TestSettingsList(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"bitbucketAuth":{`,
|
||||
`"planningcenterAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
},
|
||||
@@ -80,6 +101,8 @@ func TestSettingsList(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validData := `{"meta":{"appName":"update_test"}}`
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
@@ -116,12 +139,15 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -139,6 +165,14 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"bitbucketAuth":{`,
|
||||
`"planningcenterAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"acme_test"`,
|
||||
@@ -178,12 +212,15 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"logs":{`,
|
||||
`"smtp":{`,
|
||||
`"s3":{`,
|
||||
`"backups":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"adminFileToken":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"recordFileToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
@@ -202,6 +239,14 @@ func TestSettingsSet(t *testing.T) {
|
||||
`"oidcAuth":{`,
|
||||
`"oidc2Auth":{`,
|
||||
`"oidc3Auth":{`,
|
||||
`"appleAuth":{`,
|
||||
`"instagramAuth":{`,
|
||||
`"vkAuth":{`,
|
||||
`"yandexAuth":{`,
|
||||
`"patreonAuth":{`,
|
||||
`"mailcowAuth":{`,
|
||||
`"bitbucketAuth":{`,
|
||||
`"planningcenterAuth":{`,
|
||||
`"secret":"******"`,
|
||||
`"clientSecret":"******"`,
|
||||
`"appName":"update_test"`,
|
||||
@@ -213,6 +258,28 @@ func TestSettingsSet(t *testing.T) {
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "OnSettingsAfterUpdateRequest error response",
|
||||
Method: http.MethodPatch,
|
||||
Url: "/api/settings",
|
||||
Body: strings.NewReader(validData),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
app.OnSettingsAfterUpdateRequest().Add(func(e *core.SettingsUpdateEvent) error {
|
||||
return errors.New("error")
|
||||
})
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
"OnSettingsBeforeUpdateRequest": 1,
|
||||
"OnSettingsAfterUpdateRequest": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
@@ -221,6 +288,8 @@ func TestSettingsSet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsTestS3(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -240,14 +309,44 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (no s3)",
|
||||
Name: "authorized as admin (missing body + no s3)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"filesystem":{`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid filesystem)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"invalid"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{`,
|
||||
`"filesystem":{`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid filesystem and no s3)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/test/s3",
|
||||
Body: strings.NewReader(`{"filesystem":"storage"}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"data":{}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -257,6 +356,8 @@ func TestSettingsTestS3(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSettingsTestEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
@@ -319,7 +420,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[verification] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
@@ -354,7 +455,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[password-reset] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
@@ -389,7 +490,7 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
|
||||
AfterTestFunc: func(t *testing.T, app *tests.TestApp, res *http.Response) {
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Fatalf("[email-change] Expected 1 sent email, got %d", app.TestMailer.TotalSend)
|
||||
}
|
||||
@@ -419,3 +520,118 @@ func TestSettingsTestEmail(t *testing.T) {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppleClientSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
privatePem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedKey,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []tests.ApiScenario{
|
||||
{
|
||||
Name: "unauthorized",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as auth record",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
},
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid body)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{`"data":{}`},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (empty json)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"clientId":{"code":"validation_required"`,
|
||||
`"teamId":{"code":"validation_required"`,
|
||||
`"keyId":{"code":"validation_required"`,
|
||||
`"privateKey":{"code":"validation_required"`,
|
||||
`"duration":{"code":"validation_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (invalid data)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(`{
|
||||
"clientId": "",
|
||||
"teamId": "123456789",
|
||||
"keyId": "123456789",
|
||||
"privateKey": "invalid",
|
||||
"duration": -1
|
||||
}`),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{
|
||||
`"clientId":{"code":"validation_required"`,
|
||||
`"teamId":{"code":"validation_length_invalid"`,
|
||||
`"keyId":{"code":"validation_length_invalid"`,
|
||||
`"privateKey":{"code":"validation_match_invalid"`,
|
||||
`"duration":{"code":"validation_min_greater_equal_than_required"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "authorized as admin (valid data)",
|
||||
Method: http.MethodPost,
|
||||
Url: "/api/settings/apple/generate-client-secret",
|
||||
Body: strings.NewReader(fmt.Sprintf(`{
|
||||
"clientId": "123",
|
||||
"teamId": "1234567890",
|
||||
"keyId": "1234567891",
|
||||
"privateKey": %q,
|
||||
"duration": 1
|
||||
}`, privatePem)),
|
||||
RequestHeaders: map[string]string{
|
||||
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{
|
||||
`"secret":"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewAdminCommand creates and returns new command for managing
|
||||
// admin accounts (create, update, delete).
|
||||
func NewAdminCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "admin",
|
||||
Short: "Manages admin accounts",
|
||||
}
|
||||
|
||||
command.AddCommand(adminCreateCommand(app))
|
||||
command.AddCommand(adminUpdateCommand(app))
|
||||
command.AddCommand(adminDeleteCommand(app))
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminCreateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "create",
|
||||
Example: "admin create test@example.com 1234567890",
|
||||
Short: "Creates a new admin account",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
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.")
|
||||
}
|
||||
|
||||
if len(args[1]) < 8 {
|
||||
return errors.New("The password must be at least 8 chars long.")
|
||||
}
|
||||
|
||||
admin := &models.Admin{}
|
||||
admin.Email = args[0]
|
||||
admin.SetPassword(args[1])
|
||||
|
||||
if !app.Dao().HasTable(admin.TableName()) {
|
||||
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to create new admin account: %v", err)
|
||||
}
|
||||
|
||||
color.Green("Successfully created new admin %s!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminUpdateCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "update",
|
||||
Example: "admin update test@example.com 1234567890",
|
||||
Short: "Changes the password of a single admin account",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) != 2 {
|
||||
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.")
|
||||
}
|
||||
|
||||
if len(args[1]) < 8 {
|
||||
return errors.New("The new password must be at least 8 chars long.")
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable((&models.Admin{}).TableName()) {
|
||||
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
|
||||
}
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Admin with email %s doesn't exist.", args[0])
|
||||
}
|
||||
|
||||
admin.SetPassword(args[1])
|
||||
|
||||
if err := app.Dao().SaveAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to change admin %s password: %v", admin.Email, err)
|
||||
}
|
||||
|
||||
color.Green("Successfully changed admin %s password!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func adminDeleteCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "delete",
|
||||
Example: "admin delete test@example.com",
|
||||
Short: "Deletes an existing admin account",
|
||||
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.")
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable((&models.Admin{}).TableName()) {
|
||||
return errors.New("Migration are not initialized yet. Please run 'migrate up' and try again.")
|
||||
}
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail(args[0])
|
||||
if err != nil {
|
||||
color.Yellow("Admin %s is already deleted.", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteAdmin(admin); err != nil {
|
||||
return fmt.Errorf("Failed to delete admin %s: %v", admin.Email, err)
|
||||
}
|
||||
|
||||
color.Green("Successfully deleted admin %s!", admin.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/cmd"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminCreateCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email and password",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"duplicated email",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"test@example.com",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"short password",
|
||||
"test_new@example.com",
|
||||
"1234567",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid email and password",
|
||||
"test_new@example.com",
|
||||
"12345678",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"create", s.email, s.password})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin account was actually created
|
||||
admin, err := app.Dao().FindAdminByEmail(s.email)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch created admin %s: %v", s.name, s.email, err)
|
||||
} else if !admin.ValidatePassword(s.password) {
|
||||
t.Errorf("[%s] Expected the admin password to match", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email and password",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting admin",
|
||||
"test_missing@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"test@example.com",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"short password",
|
||||
"test_new@example.com",
|
||||
"1234567",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid email and password",
|
||||
"test@example.com",
|
||||
"12345678",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"update", s.email, s.password})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin password was actually changed
|
||||
admin, err := app.Dao().FindAdminByEmail(s.email)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch admin %s: %v", s.name, s.email, err)
|
||||
} else if !admin.ValidatePassword(s.password) {
|
||||
t.Errorf("[%s] Expected the admin password to match", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeleteCommand(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nonexisting admin",
|
||||
"test_missing@example.com",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing admin",
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
command := cmd.NewAdminCommand(app)
|
||||
command.SetArgs([]string{"delete", s.email})
|
||||
|
||||
err := command.Execute()
|
||||
|
||||
hasErr := err != nil
|
||||
if s.expectError != hasErr {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether the admin account was actually deleted
|
||||
if _, err := app.Dao().FindAdminByEmail(s.email); err == nil {
|
||||
t.Errorf("[%s] Expected the admin account to be deleted", s.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-136
@@ -1,25 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// NewServeCommand creates and returns new command responsible for
|
||||
@@ -30,100 +17,38 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
var httpsAddr string
|
||||
|
||||
command := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Starts the web server (default to 127.0.0.1:8090)",
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
// ensure that the latest migrations are applied before starting the server
|
||||
if err := runMigrations(app); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// reload app settings in case a new default value was set with a migration
|
||||
// (or if this is the first time the init migration was executed)
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
color.Yellow("=====================================")
|
||||
color.Yellow("WARNING: Settings load error! \n%v", err)
|
||||
color.Yellow("Fallback to the application defaults.")
|
||||
color.Yellow("=====================================")
|
||||
}
|
||||
|
||||
router, err := apis.InitApi(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// configure cors
|
||||
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
Skipper: middleware.DefaultSkipper,
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
|
||||
}))
|
||||
|
||||
// start http server
|
||||
// ---
|
||||
mainAddr := httpAddr
|
||||
if httpsAddr != "" {
|
||||
mainAddr = httpsAddr
|
||||
}
|
||||
|
||||
mainHost, _, _ := net.SplitHostPort(mainAddr)
|
||||
|
||||
certManager := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(app.DataDir(), ".autocert_cache")),
|
||||
HostPolicy: autocert.HostWhitelist(mainHost, "www."+mainHost),
|
||||
}
|
||||
|
||||
serverConfig := &http.Server{
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: certManager.GetCertificate,
|
||||
NextProtos: []string{acme.ALPNProto},
|
||||
},
|
||||
ReadTimeout: 5 * time.Minute,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
// WriteTimeout: 60 * time.Second, // breaks sse!
|
||||
Handler: router,
|
||||
Addr: mainAddr,
|
||||
}
|
||||
|
||||
if showStartBanner {
|
||||
schema := "http"
|
||||
if httpsAddr != "" {
|
||||
schema = "https"
|
||||
Use: "serve [domain(s)]",
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Short: "Starts the web server (default to 127.0.0.1:8090 if no domain is specified)",
|
||||
SilenceUsage: true,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
// set default listener addresses if at least one domain is specified
|
||||
if len(args) > 0 {
|
||||
if httpAddr == "" {
|
||||
httpAddr = "0.0.0.0:80"
|
||||
}
|
||||
|
||||
date := new(strings.Builder)
|
||||
log.New(date, "", log.LstdFlags).Print()
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Printf(
|
||||
"%s Server started at %s\n",
|
||||
strings.TrimSpace(date.String()),
|
||||
color.CyanString("%s://%s", schema, serverConfig.Addr),
|
||||
)
|
||||
|
||||
regular := color.New()
|
||||
regular.Printf(" ➜ REST API: %s\n", color.CyanString("%s://%s/api/", schema, serverConfig.Addr))
|
||||
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
|
||||
}
|
||||
|
||||
var serveErr error
|
||||
if httpsAddr != "" {
|
||||
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
|
||||
if httpAddr != "" {
|
||||
go http.ListenAndServe(httpAddr, certManager.HTTPHandler(nil))
|
||||
if httpsAddr == "" {
|
||||
httpsAddr = "0.0.0.0:443"
|
||||
}
|
||||
|
||||
// start HTTPS server
|
||||
serveErr = serverConfig.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
// start HTTP server
|
||||
serveErr = serverConfig.ListenAndServe()
|
||||
if httpAddr == "" {
|
||||
httpAddr = "127.0.0.1:8090"
|
||||
}
|
||||
}
|
||||
|
||||
if serveErr != http.ErrServerClosed {
|
||||
log.Fatalln(serveErr)
|
||||
_, err := apis.Serve(app, apis.ServeConfig{
|
||||
HttpAddr: httpAddr,
|
||||
HttpsAddr: httpsAddr,
|
||||
ShowStartBanner: showStartBanner,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
CertificateDomains: args,
|
||||
})
|
||||
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
@@ -137,47 +62,16 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
|
||||
command.PersistentFlags().StringVar(
|
||||
&httpAddr,
|
||||
"http",
|
||||
"127.0.0.1:8090",
|
||||
"api HTTP server address",
|
||||
"",
|
||||
"TCP address to listen for the HTTP server\n(if domain args are specified - default to 0.0.0.0:80, otherwise - default to 127.0.0.1:8090)",
|
||||
)
|
||||
|
||||
command.PersistentFlags().StringVar(
|
||||
&httpsAddr,
|
||||
"https",
|
||||
"",
|
||||
"api HTTPS server address (auto TLS via Let's Encrypt)\nthe incoming --http address traffic also will be redirected to this address",
|
||||
"TCP address to listen for the HTTPS server\n(if domain args are specified - default to 0.0.0.0:443, otherwise - default to empty string, aka. no TLS)\nThe incoming HTTP traffic also will be auto redirected to the HTTPS version",
|
||||
)
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
type migrationsConnection struct {
|
||||
DB *dbx.DB
|
||||
MigrationsList migrate.MigrationsList
|
||||
}
|
||||
|
||||
func runMigrations(app core.App) error {
|
||||
connections := []migrationsConnection{
|
||||
{
|
||||
DB: app.DB(),
|
||||
MigrationsList: migrations.AppMigrations,
|
||||
},
|
||||
{
|
||||
DB: app.LogsDB(),
|
||||
MigrationsList: logs.LogsMigrations,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range connections {
|
||||
runner, err := migrate.NewRunner(c.DB, c.MigrationsList)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := runner.Up(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9+
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
|
||||
Long: `
|
||||
Upgrades your existing pb_data to be compatible with the v0.8.x changes
|
||||
Prerequisites and caveats:
|
||||
- already upgraded to v0.7.*
|
||||
- no existing users collection
|
||||
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
|
||||
`,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
if err := upgrade(app); err != nil {
|
||||
color.Red("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func upgrade(app core.App) error {
|
||||
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
|
||||
}
|
||||
|
||||
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := migrateCollections(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUsers(app, txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resetMigrationsTable(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Println("The pb_data upgrade completed successfully!")
|
||||
bold.Println("You can now start the application as usual with the 'serve' command.")
|
||||
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func migrateCollections(txDao *daos.Dao) error {
|
||||
// add new collection columns
|
||||
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleReplacements := []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"expand", "expand2"},
|
||||
{"collecitonId", "collectionId2"},
|
||||
{"collecitonName", "collectionName2"},
|
||||
{"profile.userId", "profile.id"},
|
||||
|
||||
// @collection.*
|
||||
{"@collection.profiles.userId", "@collection.users.id"},
|
||||
{"@collection.profiles.username", "@collection.users.username2"},
|
||||
{"@collection.profiles.email", "@collection.users.email2"},
|
||||
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
|
||||
{"@collection.profiles.verified", "@collection.users.verified2"},
|
||||
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
|
||||
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
|
||||
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
|
||||
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
|
||||
{"@collection.profiles.", "@collection.users."},
|
||||
|
||||
// @request.*
|
||||
{"@request.user.profile.userId", "@request.auth.id"},
|
||||
{"@request.user.profile.username", "@request.auth.username2"},
|
||||
{"@request.user.profile.email", "@request.auth.email2"},
|
||||
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
|
||||
{"@request.user.profile.verified", "@request.auth.verified2"},
|
||||
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
|
||||
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
|
||||
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
|
||||
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
|
||||
{"@request.user.profile.", "@request.auth."},
|
||||
{"@request.user", "@request.auth"},
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.NormalizeOptions()
|
||||
|
||||
// rename profile fields
|
||||
// ---
|
||||
fieldsToRename := []string{
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"expand",
|
||||
}
|
||||
if collection.Name == "profiles" {
|
||||
fieldsToRename = append(fieldsToRename,
|
||||
"username",
|
||||
"email",
|
||||
"emailVisibility",
|
||||
"verified",
|
||||
"tokenKey",
|
||||
"passwordHash",
|
||||
"lastResetSentAt",
|
||||
"lastVerificationSentAt",
|
||||
)
|
||||
}
|
||||
for _, name := range fieldsToRename {
|
||||
f := collection.Schema.GetFieldByName(name)
|
||||
if f != nil {
|
||||
color.Blue("[%s - renamed field]", collection.Name)
|
||||
color.Yellow(" - old: %s", f.Name)
|
||||
color.Green(" - new: %s2", f.Name)
|
||||
fmt.Println()
|
||||
f.Name += "2"
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
// replace rule fields
|
||||
// ---
|
||||
rules := map[string]*string{
|
||||
"ListRule": collection.ListRule,
|
||||
"ViewRule": collection.ViewRule,
|
||||
"CreateRule": collection.CreateRule,
|
||||
"UpdateRule": collection.UpdateRule,
|
||||
"DeleteRule": collection.DeleteRule,
|
||||
}
|
||||
|
||||
for ruleKey, rule := range rules {
|
||||
if rule == nil || *rule == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
originalRule := *rule
|
||||
|
||||
for _, replacement := range ruleReplacements {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
|
||||
*rule = re.ReplaceAllString(*rule, replacement.new)
|
||||
}
|
||||
|
||||
*rule = replaceReversedLikes(*rule)
|
||||
|
||||
if originalRule != *rule {
|
||||
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
|
||||
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
|
||||
color.Green(" - new: %s", strings.TrimSpace(*rule))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
if err := txDao.SaveCollection(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(app core.App, txDao *daos.Dao) error {
|
||||
color.Blue(`[merging "_users" and "profiles"]:`)
|
||||
|
||||
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalProfilesCollectionId := profilesCollection.Id
|
||||
|
||||
// change the profiles collection id to something else since we will be using
|
||||
// it for the new users collection in order to avoid renaming the storage dir
|
||||
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{_collections}}
|
||||
SET id = '%s'
|
||||
WHERE id = '%s';
|
||||
`,
|
||||
(originalProfilesCollectionId + "__old__"),
|
||||
originalProfilesCollectionId,
|
||||
)).Execute()
|
||||
if idRenameErr != nil {
|
||||
return idRenameErr
|
||||
}
|
||||
|
||||
// refresh profiles collection
|
||||
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersSchema, _ := profilesCollection.Schema.Clone()
|
||||
userIdField := usersSchema.GetFieldByName("userId")
|
||||
if userIdField != nil {
|
||||
usersSchema.RemoveField(userIdField.Id)
|
||||
}
|
||||
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
usersCollection.Id = originalProfilesCollectionId
|
||||
usersCollection.Name = "users"
|
||||
usersCollection.Type = models.CollectionTypeAuth
|
||||
usersCollection.Schema = *usersSchema
|
||||
usersCollection.CreateRule = types.Pointer("")
|
||||
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
|
||||
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
|
||||
usersCollection.ListRule = profilesCollection.ListRule
|
||||
}
|
||||
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
|
||||
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
|
||||
usersCollection.ViewRule = profilesCollection.ViewRule
|
||||
}
|
||||
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
|
||||
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
|
||||
usersCollection.UpdateRule = profilesCollection.UpdateRule
|
||||
}
|
||||
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
|
||||
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
|
||||
usersCollection.DeleteRule = profilesCollection.DeleteRule
|
||||
}
|
||||
|
||||
// set auth options
|
||||
settings := app.Settings()
|
||||
authOptions := usersCollection.AuthOptions()
|
||||
authOptions.ManageRule = nil
|
||||
authOptions.AllowOAuth2Auth = true
|
||||
authOptions.AllowUsernameAuth = false
|
||||
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
|
||||
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
|
||||
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
|
||||
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
|
||||
// twitter currently is the only provider that doesn't return an email
|
||||
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
|
||||
|
||||
usersCollection.SetOptions(authOptions)
|
||||
|
||||
if err := txDao.SaveCollection(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy the original users
|
||||
_, usersErr := txDao.DB().NewQuery(`
|
||||
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
|
||||
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
|
||||
FROM {{_users}};
|
||||
`).Execute()
|
||||
if usersErr != nil {
|
||||
return usersErr
|
||||
}
|
||||
|
||||
// generate the profile fields copy statements
|
||||
sets := []string{"id = p.id"}
|
||||
for _, f := range usersSchema.Fields() {
|
||||
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
|
||||
}
|
||||
|
||||
// copy profile fields
|
||||
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE {{users}} as u
|
||||
SET %s
|
||||
FROM {{profiles}} as p
|
||||
WHERE u.id = p.userId;
|
||||
`, strings.Join(sets, ", "))).Execute()
|
||||
if copyProfileErr != nil {
|
||||
return copyProfileErr
|
||||
}
|
||||
|
||||
profileRecords, err := txDao.FindRecordsByExpr("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update all profiles and users fields to point to the new users collection
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, collection := range collections {
|
||||
var hasChanges bool
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
f.InitOptions()
|
||||
|
||||
if f.Type == schema.FieldTypeUser {
|
||||
if collection.Name == "profiles" && f.Name == "userId" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasChanges = true
|
||||
|
||||
// change the user field to a relation field
|
||||
options, _ := f.Options.(*schema.UserOptions)
|
||||
f.Type = schema.FieldTypeRelation
|
||||
f.Options = &schema.RelationOptions{
|
||||
CollectionId: usersCollection.Id,
|
||||
MaxSelect: &options.MaxSelect,
|
||||
CascadeDelete: options.CascadeDelete,
|
||||
}
|
||||
|
||||
for _, p := range profileRecords {
|
||||
pId := p.Id
|
||||
pUserId := p.GetString("userId")
|
||||
// replace all user record id references with the profile id
|
||||
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
|
||||
WHERE [[%s]] LIKE ('%%%s%%');
|
||||
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop _users table
|
||||
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop profiles table
|
||||
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete profiles collection
|
||||
if err := txDao.Delete(profilesCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
|
||||
_, alterErr := txDao.DB().NewQuery(`
|
||||
-- crate new externalAuths table
|
||||
CREATE TABLE {{_newExternalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- copy all data from the old table to the new one
|
||||
INSERT INTO {{_newExternalAuths}}
|
||||
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
|
||||
FROM {{_externalAuths}} auth
|
||||
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
|
||||
|
||||
-- drop old table
|
||||
DROP TABLE {{_externalAuths}};
|
||||
|
||||
-- rename new table
|
||||
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
|
||||
|
||||
-- create named indexes
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
|
||||
return alterErr
|
||||
}
|
||||
|
||||
func resetMigrationsTable(txDao *daos.Dao) error {
|
||||
// reset the migration state to the new init
|
||||
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
|
||||
"file": "1661586591_add_externalAuths_table.go",
|
||||
}).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
|
||||
|
||||
func replaceReversedLikes(rule string) string {
|
||||
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if len(p) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
|
||||
|
||||
rule = strings.ReplaceAll(rule, p[0], newPart)
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
+146
-91
@@ -4,6 +4,9 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
@@ -46,6 +49,9 @@ type App interface {
|
||||
// the users table from LogsDao will result in error.
|
||||
LogsDao() *daos.Dao
|
||||
|
||||
// Logger returns the active app logger.
|
||||
Logger() *slog.Logger
|
||||
|
||||
// DataDir returns the app data directory path.
|
||||
DataDir() string
|
||||
|
||||
@@ -53,28 +59,38 @@ type App interface {
|
||||
// (used for settings encryption).
|
||||
EncryptionEnv() string
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
IsDebug() bool
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
IsDev() bool
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
Settings() *settings.Settings
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
Cache() *store.Store[any]
|
||||
|
||||
// Store returns the app runtime store.
|
||||
Store() *store.Store[any]
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
SubscriptionsBroker() *subscriptions.Broker
|
||||
|
||||
// NewMailClient creates and returns a configured app mail client.
|
||||
NewMailClient() mailer.Mailer
|
||||
|
||||
// NewFilesystem creates and returns a configured filesystem.System instance.
|
||||
// NewFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing regular app files (eg. collection uploads).
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewFilesystem() (*filesystem.System, error)
|
||||
|
||||
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
|
||||
// for managing app backups.
|
||||
//
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
NewBackupsFilesystem() (*filesystem.System, error)
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
RefreshSettings() error
|
||||
|
||||
@@ -92,25 +108,50 @@ type App interface {
|
||||
// (eg. closing db connections).
|
||||
ResetBootstrapState() error
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
//
|
||||
// Please refer to the godoc of the specific core.App implementation
|
||||
// for details on the backup procedures.
|
||||
CreateBackup(ctx context.Context, name string) error
|
||||
|
||||
// RestoreBackup restores the backup with the specified name and restarts
|
||||
// the current running application process.
|
||||
//
|
||||
// The safely perform the restore it is recommended to have free disk space
|
||||
// for at least 2x the size of the restored pb_data backup.
|
||||
//
|
||||
// Please refer to the godoc of the specific core.App implementation
|
||||
// for details on the restore procedures.
|
||||
//
|
||||
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
|
||||
RestoreBackup(ctx context.Context, name string) error
|
||||
|
||||
// Restart restarts the current running application process.
|
||||
//
|
||||
// Currently it is relying on execve so it is supported only on UNIX based systems.
|
||||
Restart() error
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// App event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnBeforeBootstrap hook is triggered before initializing the base
|
||||
// OnBeforeBootstrap hook is triggered before initializing the main
|
||||
// application resources (eg. before db open and initial settings load).
|
||||
OnBeforeBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnAfterBootstrap hook is triggered after initializing the base
|
||||
// OnAfterBootstrap hook is triggered after initializing the main
|
||||
// application resources (eg. after db open and initial settings load).
|
||||
OnAfterBootstrap() *hook.Hook[*BootstrapEvent]
|
||||
|
||||
// OnBeforeServe hook is triggered before serving the internal router (echo),
|
||||
// allowing you to adjust its options and attach new routes.
|
||||
// allowing you to adjust its options and attach new routes or middlewares.
|
||||
OnBeforeServe() *hook.Hook[*ServeEvent]
|
||||
|
||||
// OnBeforeApiError hook is triggered right before sending an error API
|
||||
// response to the client, allowing you to further modify the error data
|
||||
// or to return a completely different API response (using [hook.StopPropagation]).
|
||||
// or to return a completely different API response.
|
||||
OnBeforeApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnAfterApiError hook is triggered right after sending an error API
|
||||
@@ -118,12 +159,16 @@ type App interface {
|
||||
// It could be used to log the final API error in external services.
|
||||
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
|
||||
|
||||
// OnTerminate hook is triggered when the app is in the process
|
||||
// of being terminated (eg. on SIGTERM signal).
|
||||
OnTerminate() *hook.Hook[*TerminateEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnModelBeforeCreate hook is triggered before inserting a new
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -131,7 +176,7 @@ type App interface {
|
||||
OnModelBeforeCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterCreate hook is triggered after successfully
|
||||
// inserting a new entry in the DB.
|
||||
// inserting a new model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -139,7 +184,7 @@ type App interface {
|
||||
OnModelAfterCreate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeUpdate hook is triggered before updating existing
|
||||
// entry in the DB, allowing you to modify or validate the stored data.
|
||||
// model in the DB, allowing you to modify or validate the stored data.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -147,7 +192,7 @@ type App interface {
|
||||
OnModelBeforeUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterUpdate hook is triggered after successfully updating
|
||||
// existing entry in the DB.
|
||||
// existing model in the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -155,15 +200,15 @@ type App interface {
|
||||
OnModelAfterUpdate(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelBeforeDelete hook is triggered before deleting an
|
||||
// existing entry from the DB.
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
// will be triggered and called only if their event data origin matches the tags.
|
||||
OnModelBeforeDelete(tags ...string) *hook.TaggedHook[*ModelEvent]
|
||||
|
||||
// OnModelAfterDelete is triggered after successfully deleting an
|
||||
// existing entry from the DB.
|
||||
// OnModelAfterDelete hook is triggered after successfully deleting an
|
||||
// existing model from the DB.
|
||||
//
|
||||
// If the optional "tags" list (table names and/or the Collection id for Record models)
|
||||
// is specified, then all event handlers registered via the created hook
|
||||
@@ -174,22 +219,18 @@ type App interface {
|
||||
// Mailer event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an admin.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeAdminResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an admin, allowing you
|
||||
// to inspect and customize the email message that is being sent.
|
||||
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerAfterAdminResetPasswordSend hook is triggered after
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right
|
||||
// before sending a password reset email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -204,11 +245,9 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnMailerAfterRecordResetPasswordSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right
|
||||
// before sending a verification email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -224,10 +263,8 @@ type App interface {
|
||||
OnMailerAfterRecordVerificationSend(tags ...string) *hook.TaggedHook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
// sending a confirmation new address email to an auth record, allowing
|
||||
// you to inspect and customize the email message that is being sent.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -254,14 +291,14 @@ type App interface {
|
||||
// SSE client connection.
|
||||
OnRealtimeDisconnectRequest() *hook.Hook[*RealtimeDisconnectEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right before sending
|
||||
// OnRealtimeBeforeMessageSend hook is triggered right before sending
|
||||
// an SSE message to a client.
|
||||
//
|
||||
// Returning [hook.StopPropagation] will prevent sending the message.
|
||||
// Returning any other non-nil error will close the realtime connection.
|
||||
OnRealtimeBeforeMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
// OnRealtimeBeforeMessage hook is triggered right after sending
|
||||
// OnRealtimeAfterMessageSend hook is triggered right after sending
|
||||
// an SSE message to a client.
|
||||
OnRealtimeAfterMessageSend() *hook.Hook[*RealtimeMessageEvent]
|
||||
|
||||
@@ -289,8 +326,7 @@ type App interface {
|
||||
// Settings update request (after request data load and before settings persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or
|
||||
// implement completely different persistence behavior
|
||||
// (returning [hook.StopPropagation]).
|
||||
// implement completely different persistence behavior.
|
||||
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
|
||||
|
||||
// OnSettingsAfterUpdateRequest hook is triggered after each
|
||||
@@ -307,6 +343,25 @@ type App interface {
|
||||
// returning it to the client.
|
||||
OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*FileDownloadEvent]
|
||||
|
||||
// OnFileBeforeTokenRequest hook is triggered before each file
|
||||
// token API request.
|
||||
//
|
||||
// If no token or model was submitted, e.Model and e.Token will be empty,
|
||||
// allowing you to implement your own custom model file auth implementation.
|
||||
//
|
||||
// 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.
|
||||
OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
|
||||
|
||||
// OnFileAfterTokenRequest hook is triggered after each
|
||||
// successful 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.
|
||||
OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -325,7 +380,7 @@ type App interface {
|
||||
// Admin create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
|
||||
|
||||
// OnAdminAfterCreateRequest hook is triggered after each
|
||||
@@ -336,7 +391,7 @@ type App interface {
|
||||
// Admin update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
|
||||
|
||||
// OnAdminAfterUpdateRequest hook is triggered after each
|
||||
@@ -347,7 +402,7 @@ type App interface {
|
||||
// Admin delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
|
||||
|
||||
// OnAdminAfterDeleteRequest hook is triggered after each
|
||||
@@ -376,7 +431,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
OnAdminBeforeAuthRefreshRequest() *hook.Hook[*AdminAuthRefreshEvent]
|
||||
|
||||
// OnAdminAfterAuthRefreshRequest hook is triggered after each
|
||||
@@ -387,7 +442,7 @@ type App interface {
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
OnAdminBeforeRequestPasswordResetRequest() *hook.Hook[*AdminRequestPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterRequestPasswordResetRequest hook is triggered after each
|
||||
@@ -398,7 +453,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnAdminBeforeConfirmPasswordResetRequest() *hook.Hook[*AdminConfirmPasswordResetEvent]
|
||||
|
||||
// OnAdminAfterConfirmPasswordResetRequest hook is triggered after each
|
||||
@@ -424,7 +479,7 @@ type App interface {
|
||||
// auth with password API request (after request data load and before password validation).
|
||||
//
|
||||
// Could be used to implement for example a custom password validation
|
||||
// or to locate a different Record identity (by assigning [RecordAuthWithPasswordEvent.Record]).
|
||||
// or to locate a different Record model (by reassigning [RecordAuthWithPasswordEvent.Record]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -442,11 +497,11 @@ type App interface {
|
||||
// OnRecordBeforeAuthWithOAuth2Request hook is triggered before each Record
|
||||
// OAuth2 sign-in/sign-up API request (after token exchange and before external provider linking).
|
||||
//
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is nil, then the OAuth2
|
||||
// If the [RecordAuthWithOAuth2Event.Record] is not set, then the OAuth2
|
||||
// request will try to create a new auth Record.
|
||||
//
|
||||
// To assign or link a different existing record model you can
|
||||
// overwrite/modify the [RecordAuthWithOAuth2Event.Record] field.
|
||||
// change the [RecordAuthWithOAuth2Event.Record] field.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -465,7 +520,7 @@ type App interface {
|
||||
// auth refresh API request (right before generating a new auth token).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different auth refresh behavior (returning [hook.StopPropagation]).
|
||||
// completely different auth refresh behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -480,11 +535,39 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterAuthRefreshRequest(tags ...string) *hook.TaggedHook[*RecordAuthRefreshEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordBeforeRequestPasswordResetRequest hook is triggered before each Record
|
||||
// request password reset API request (after request data load and before sending the reset email).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different password reset behavior (returning [hook.StopPropagation]).
|
||||
// completely different password reset behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -503,7 +586,7 @@ type App interface {
|
||||
// confirm password reset API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -522,7 +605,7 @@ type App interface {
|
||||
// request verification API request (after request data load and before sending the verification email).
|
||||
//
|
||||
// Could be used to additionally validate the loaded request data or implement
|
||||
// completely different verification behavior (returning [hook.StopPropagation]).
|
||||
// completely different verification behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -541,7 +624,7 @@ type App interface {
|
||||
// confirm verification API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -560,7 +643,7 @@ type App interface {
|
||||
// (after request data load and before sending the email link to confirm the change).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different request email change behavior (returning [hook.StopPropagation]).
|
||||
// completely different request email change behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -579,7 +662,7 @@ type App interface {
|
||||
// confirm email change API request (after request data load and before persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -594,34 +677,6 @@ type App interface {
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterConfirmEmailChangeRequest(tags ...string) *hook.TaggedHook[*RecordConfirmEmailChangeEvent]
|
||||
|
||||
// OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordListExternalAuthsRequest(tags ...string) *hook.TaggedHook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordBeforeUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
// triggered and called only if their event data origin matches the tags.
|
||||
OnRecordAfterUnlinkExternalAuthRequest(tags ...string) *hook.TaggedHook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record CRUD API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
@@ -648,7 +703,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -667,7 +722,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -686,7 +741,7 @@ type App interface {
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
//
|
||||
// If the optional "tags" list (Collection ids or names) is specified,
|
||||
// then all event handlers registered via the created hook will be
|
||||
@@ -719,7 +774,7 @@ type App interface {
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
|
||||
|
||||
// OnCollectionAfterCreateRequest hook is triggered after each
|
||||
@@ -730,7 +785,7 @@ type App interface {
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
// completely different persistence behavior.
|
||||
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
|
||||
|
||||
// OnCollectionAfterUpdateRequest hook is triggered after each
|
||||
@@ -741,7 +796,7 @@ type App interface {
|
||||
// Collection delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
// completely different delete behavior.
|
||||
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
|
||||
|
||||
// OnCollectionAfterDeleteRequest hook is triggered after each
|
||||
@@ -752,7 +807,7 @@ type App interface {
|
||||
// collections import request (after request data load and before the actual import).
|
||||
//
|
||||
// Could be used to additionally validate the imported collections or
|
||||
// to implement completely different import behavior (returning [hook.StopPropagation]).
|
||||
// to implement completely different import behavior.
|
||||
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
|
||||
|
||||
// OnCollectionsAfterImportRequest hook is triggered after each
|
||||
|
||||
+301
-57
@@ -5,8 +5,11 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@@ -16,10 +19,14 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/settings"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/routine"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,14 +34,20 @@ const (
|
||||
DefaultDataMaxIdleConns int = 20
|
||||
DefaultLogsMaxOpenConns int = 10
|
||||
DefaultLogsMaxIdleConns int = 2
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
var _ App = (*BaseApp)(nil)
|
||||
|
||||
// BaseApp implements core.App and defines the base PocketBase app structure.
|
||||
type BaseApp struct {
|
||||
// @todo consider introducing a mutex to allow safe concurrent config changes during runtime
|
||||
|
||||
// configurable parameters
|
||||
isDebug bool
|
||||
isDev bool
|
||||
dataDir string
|
||||
encryptionEnv string
|
||||
dataMaxOpenConns int
|
||||
@@ -43,11 +56,12 @@ type BaseApp struct {
|
||||
logsMaxIdleConns int
|
||||
|
||||
// internals
|
||||
cache *store.Store[any]
|
||||
store *store.Store[any]
|
||||
settings *settings.Settings
|
||||
dao *daos.Dao
|
||||
logsDao *daos.Dao
|
||||
subscriptionsBroker *subscriptions.Broker
|
||||
logger *slog.Logger
|
||||
|
||||
// app event hooks
|
||||
onBeforeBootstrap *hook.Hook[*BootstrapEvent]
|
||||
@@ -55,6 +69,7 @@ type BaseApp struct {
|
||||
onBeforeServe *hook.Hook[*ServeEvent]
|
||||
onBeforeApiError *hook.Hook[*ApiErrorEvent]
|
||||
onAfterApiError *hook.Hook[*ApiErrorEvent]
|
||||
onTerminate *hook.Hook[*TerminateEvent]
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate *hook.Hook[*ModelEvent]
|
||||
@@ -88,7 +103,9 @@ type BaseApp struct {
|
||||
onSettingsAfterUpdateRequest *hook.Hook[*SettingsUpdateEvent]
|
||||
|
||||
// file api event hooks
|
||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||
onFileDownloadRequest *hook.Hook[*FileDownloadEvent]
|
||||
onFileBeforeTokenRequest *hook.Hook[*FileTokenEvent]
|
||||
onFileAfterTokenRequest *hook.Hook[*FileTokenEvent]
|
||||
|
||||
// admin api event hooks
|
||||
onAdminsListRequest *hook.Hook[*AdminsListEvent]
|
||||
@@ -158,9 +175,9 @@ type BaseApp struct {
|
||||
|
||||
// BaseAppConfig defines a BaseApp configuration option
|
||||
type BaseAppConfig struct {
|
||||
IsDev bool
|
||||
DataDir string
|
||||
EncryptionEnv string
|
||||
IsDebug bool
|
||||
DataMaxOpenConns int // default to 500
|
||||
DataMaxIdleConns int // default 20
|
||||
LogsMaxOpenConns int // default to 100
|
||||
@@ -171,16 +188,16 @@ type BaseAppConfig struct {
|
||||
// configured with the provided arguments.
|
||||
//
|
||||
// To initialize the app, you need to call `app.Bootstrap()`.
|
||||
func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
func NewBaseApp(config BaseAppConfig) *BaseApp {
|
||||
app := &BaseApp{
|
||||
isDev: config.IsDev,
|
||||
dataDir: config.DataDir,
|
||||
isDebug: config.IsDebug,
|
||||
encryptionEnv: config.EncryptionEnv,
|
||||
dataMaxOpenConns: config.DataMaxOpenConns,
|
||||
dataMaxIdleConns: config.DataMaxIdleConns,
|
||||
logsMaxOpenConns: config.LogsMaxOpenConns,
|
||||
logsMaxIdleConns: config.LogsMaxIdleConns,
|
||||
cache: store.New[any](nil),
|
||||
store: store.New[any](nil),
|
||||
settings: settings.New(),
|
||||
subscriptionsBroker: subscriptions.NewBroker(),
|
||||
|
||||
@@ -190,6 +207,7 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
onBeforeServe: &hook.Hook[*ServeEvent]{},
|
||||
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
|
||||
onTerminate: &hook.Hook[*TerminateEvent]{},
|
||||
|
||||
// dao event hooks
|
||||
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
|
||||
@@ -223,7 +241,9 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
|
||||
onSettingsAfterUpdateRequest: &hook.Hook[*SettingsUpdateEvent]{},
|
||||
|
||||
// file API event hooks
|
||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||
onFileDownloadRequest: &hook.Hook[*FileDownloadEvent]{},
|
||||
onFileBeforeTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||
onFileAfterTokenRequest: &hook.Hook[*FileTokenEvent]{},
|
||||
|
||||
// admin API event hooks
|
||||
onAdminsListRequest: &hook.Hook[*AdminsListEvent]{},
|
||||
@@ -302,6 +322,17 @@ func (app *BaseApp) IsBootstrapped() bool {
|
||||
return app.dao != nil && app.logsDao != nil && app.settings != nil
|
||||
}
|
||||
|
||||
// Logger returns the default app logger.
|
||||
//
|
||||
// If the application is not bootstrapped yet, fallbacks to slog.Default().
|
||||
func (app *BaseApp) Logger() *slog.Logger {
|
||||
if app.logger == nil {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
return app.logger
|
||||
}
|
||||
|
||||
// Bootstrap initializes the application
|
||||
// (aka. create data dir, open db connections, load settings, etc.).
|
||||
//
|
||||
@@ -331,14 +362,17 @@ func (app *BaseApp) Bootstrap() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.initLogger(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we don't check for an error because the db migrations may have not been executed yet
|
||||
app.RefreshSettings()
|
||||
|
||||
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
// cleanup the pb_data temp directory (if any)
|
||||
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
|
||||
|
||||
return nil
|
||||
return app.OnAfterBootstrap().Trigger(event)
|
||||
}
|
||||
|
||||
// ResetBootstrapState takes care for releasing initialized app resources
|
||||
@@ -364,7 +398,6 @@ func (app *BaseApp) ResetBootstrapState() error {
|
||||
|
||||
app.dao = nil
|
||||
app.logsDao = nil
|
||||
app.settings = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -428,10 +461,11 @@ func (app *BaseApp) EncryptionEnv() string {
|
||||
return app.encryptionEnv
|
||||
}
|
||||
|
||||
// IsDebug returns whether the app is in debug mode
|
||||
// (showing more detailed error logs, executed sql statements, etc.).
|
||||
func (app *BaseApp) IsDebug() bool {
|
||||
return app.isDebug
|
||||
// IsDev returns whether the app is in dev mode.
|
||||
//
|
||||
// When enabled logs, executed sql statements, etc. are printed to the stderr.
|
||||
func (app *BaseApp) IsDev() bool {
|
||||
return app.isDev
|
||||
}
|
||||
|
||||
// Settings returns the loaded app settings.
|
||||
@@ -439,9 +473,15 @@ func (app *BaseApp) Settings() *settings.Settings {
|
||||
return app.settings
|
||||
}
|
||||
|
||||
// Cache returns the app internal cache store.
|
||||
// Deprecated: Use app.Store() instead.
|
||||
func (app *BaseApp) Cache() *store.Store[any] {
|
||||
return app.cache
|
||||
color.Yellow("app.Store() is soft-deprecated. Please replace it with app.Store().")
|
||||
return app.Store()
|
||||
}
|
||||
|
||||
// Store returns the app internal runtime store.
|
||||
func (app *BaseApp) Store() *store.Store[any] {
|
||||
return app.store
|
||||
}
|
||||
|
||||
// SubscriptionsBroker returns the app realtime subscriptions broker instance.
|
||||
@@ -460,6 +500,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
Password: app.Settings().Smtp.Password,
|
||||
Tls: app.Settings().Smtp.Tls,
|
||||
AuthMethod: app.Settings().Smtp.AuthMethod,
|
||||
LocalName: app.Settings().Smtp.LocalName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,9 +508,10 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
|
||||
}
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing regular app files (eg. collection uploads)
|
||||
// based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call `Close()` on the returned result
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.S3.Enabled {
|
||||
@@ -484,7 +526,54 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
|
||||
}
|
||||
|
||||
// fallback to local filesystem
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage"))
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
|
||||
}
|
||||
|
||||
// NewFilesystem creates a new local or S3 filesystem instance
|
||||
// for managing app backups based on the current app settings.
|
||||
//
|
||||
// NB! Make sure to call Close() on the returned result
|
||||
// after you are done working with it.
|
||||
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
||||
if app.settings != nil && app.settings.Backups.S3.Enabled {
|
||||
return filesystem.NewS3(
|
||||
app.settings.Backups.S3.Bucket,
|
||||
app.settings.Backups.S3.Region,
|
||||
app.settings.Backups.S3.Endpoint,
|
||||
app.settings.Backups.S3.AccessKey,
|
||||
app.settings.Backups.S3.Secret,
|
||||
app.settings.Backups.S3.ForcePathStyle,
|
||||
)
|
||||
}
|
||||
|
||||
// fallback to local filesystem
|
||||
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
|
||||
}
|
||||
|
||||
// Restart restarts (aka. replaces) the current running application process.
|
||||
//
|
||||
// NB! It relies on execve which is supported only on UNIX based systems.
|
||||
func (app *BaseApp) Restart() error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("restart is not supported on windows")
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.OnTerminate().Trigger(&TerminateEvent{
|
||||
App: app,
|
||||
IsRestart: true,
|
||||
}, func(e *TerminateEvent) error {
|
||||
e.App.ResetBootstrapState()
|
||||
|
||||
// attempt to restart the bootstrap process in case execve returns an error for some reason
|
||||
defer e.App.Bootstrap()
|
||||
|
||||
return syscall.Exec(execPath, os.Args, os.Environ())
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshSettings reinitializes and reloads the stored application settings.
|
||||
@@ -496,7 +585,7 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
encryptionKey := os.Getenv(app.EncryptionEnv())
|
||||
|
||||
storedSettings, err := app.Dao().FindSettings(encryptionKey)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -510,6 +599,13 @@ func (app *BaseApp) RefreshSettings() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// reload handler level (if initialized)
|
||||
if app.Logger() != nil {
|
||||
if h, ok := app.Logger().Handler().(*logger.BatchHandler); ok {
|
||||
h.SetLevel(app.getLoggerMinLevel())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -537,6 +633,10 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
|
||||
return app.onAfterApiError
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
|
||||
return app.onTerminate
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Dao event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -653,6 +753,14 @@ func (app *BaseApp) OnFileDownloadRequest(tags ...string) *hook.TaggedHook[*File
|
||||
return hook.NewTaggedHook(app.onFileDownloadRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFileBeforeTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
|
||||
return hook.NewTaggedHook(app.onFileBeforeTokenRequest, tags...)
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnFileAfterTokenRequest(tags ...string) *hook.TaggedHook[*FileTokenEvent] {
|
||||
return hook.NewTaggedHook(app.onFileAfterTokenRequest, tags...)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Admin API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
@@ -917,7 +1025,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "logs.db"))
|
||||
if err != nil {
|
||||
@@ -925,7 +1033,7 @@ func (app *BaseApp) initLogsDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
app.logsDao = daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
@@ -948,7 +1056,7 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
concurrentDB.DB().SetMaxOpenConns(maxOpenConns)
|
||||
concurrentDB.DB().SetMaxIdleConns(maxIdleConns)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
concurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
nonconcurrentDB, err := connectDB(filepath.Join(app.DataDir(), "data.db"))
|
||||
if err != nil {
|
||||
@@ -956,17 +1064,16 @@ func (app *BaseApp) initDataDB() error {
|
||||
}
|
||||
nonconcurrentDB.DB().SetMaxOpenConns(1)
|
||||
nonconcurrentDB.DB().SetMaxIdleConns(1)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(5 * time.Minute)
|
||||
nonconcurrentDB.DB().SetConnMaxIdleTime(3 * time.Minute)
|
||||
|
||||
if app.IsDebug() {
|
||||
if app.IsDev() {
|
||||
nonconcurrentDB.QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
|
||||
nonconcurrentDB.ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
|
||||
color.HiBlack("[%.2fms] %v\n", float64(t.Milliseconds()), sql)
|
||||
}
|
||||
concurrentDB.QueryLogFunc = nonconcurrentDB.QueryLogFunc
|
||||
concurrentDB.ExecLogFunc = nonconcurrentDB.ExecLogFunc
|
||||
}
|
||||
|
||||
@@ -978,37 +1085,58 @@ func (app *BaseApp) initDataDB() error {
|
||||
func (app *BaseApp) createDaoWithHooks(concurrentDB, nonconcurrentDB dbx.Builder) *daos.Dao {
|
||||
dao := daos.NewMultiDB(concurrentDB, nonconcurrentDB)
|
||||
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeCreate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterCreate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelAfterCreate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeUpdate().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterUpdate().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelAfterUpdate().Trigger(e)
|
||||
}
|
||||
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
return app.OnModelBeforeDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelBeforeDelete().Trigger(e, func(e *ModelEvent) error {
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
err := app.OnModelAfterDelete().Trigger(&ModelEvent{eventDao, m})
|
||||
if err != nil && app.isDebug {
|
||||
log.Println(err)
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
e := new(ModelEvent)
|
||||
e.Dao = eventDao
|
||||
e.Model = m
|
||||
|
||||
return app.OnModelAfterDelete().Trigger(e)
|
||||
}
|
||||
|
||||
return dao
|
||||
@@ -1037,18 +1165,134 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
|
||||
// run in the background for "optimistic" delete to avoid
|
||||
// blocking the delete transaction
|
||||
//
|
||||
// @todo consider creating a bg process queue so that the
|
||||
// call could be "retried" in case of a failure.
|
||||
routine.FireAndForget(func() {
|
||||
if err := deletePrefix(prefix); err != nil && app.IsDebug() {
|
||||
// non critical error - only log for debug
|
||||
// (usually could happen because of S3 api limits)
|
||||
log.Println(err)
|
||||
if err := deletePrefix(prefix); err != nil {
|
||||
app.Logger().Error(
|
||||
"Failed to delete storage prefix (non critical error; usually could happen because of S3 api limits)",
|
||||
slog.String("prefix", prefix),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.initAutobackupHooks(); err != nil {
|
||||
app.Logger().Error("Failed to init auto backup hooks", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// getLoggerMinLevel returns the logger min level based on the
|
||||
// app configurations (dev mode, settings, etc.).
|
||||
//
|
||||
// If not in dev mode - returns the level from the app settings.
|
||||
//
|
||||
// If the app is in dev mode it returns -9999 level allowing to print
|
||||
// practically all logs to the terminal.
|
||||
// In this case DB logs are still filtered but the checks for the min level are done
|
||||
// in the BatchOptions.BeforeAddFunc instead of the slog.Handler.Enabled() method.
|
||||
func (app *BaseApp) getLoggerMinLevel() slog.Level {
|
||||
var minLevel slog.Level
|
||||
|
||||
if app.IsDev() {
|
||||
minLevel = -9999
|
||||
} else if app.Settings() != nil {
|
||||
minLevel = slog.Level(app.Settings().Logs.MinLevel)
|
||||
}
|
||||
|
||||
return minLevel
|
||||
}
|
||||
|
||||
func (app *BaseApp) initLogger() error {
|
||||
duration := 3 * time.Second
|
||||
ticker := time.NewTicker(duration)
|
||||
done := make(chan bool)
|
||||
|
||||
handler := logger.NewBatchHandler(logger.BatchOptions{
|
||||
Level: app.getLoggerMinLevel(),
|
||||
BatchSize: 200,
|
||||
BeforeAddFunc: func(ctx context.Context, log *logger.Log) bool {
|
||||
if app.IsDev() {
|
||||
printLog(log)
|
||||
|
||||
// manually check the log level and skip if necessary
|
||||
if log.Level < slog.Level(app.Settings().Logs.MinLevel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ticker.Reset(duration)
|
||||
|
||||
return app.Settings().Logs.MaxDays > 0
|
||||
},
|
||||
WriteFunc: func(ctx context.Context, logs []*logger.Log) error {
|
||||
if !app.IsBootstrapped() || app.Settings().Logs.MaxDays == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// write the accumulated logs
|
||||
// (note: based on several local tests there is no significant performance difference between small number of separate write queries vs 1 big INSERT)
|
||||
app.LogsDao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
model := &models.Log{}
|
||||
for _, l := range logs {
|
||||
model.MarkAsNew()
|
||||
// note: using pseudorandom for a slightly better performance
|
||||
model.Id = security.PseudorandomStringWithAlphabet(models.DefaultIdLength, models.DefaultIdAlphabet)
|
||||
model.Level = int(l.Level)
|
||||
model.Message = l.Message
|
||||
model.Data = l.Data
|
||||
model.Created, _ = types.ParseDateTime(l.Time)
|
||||
model.Updated = model.Created
|
||||
|
||||
if err := txDao.SaveLog(model); err != nil {
|
||||
log.Println("Failed to write log", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// delete old logs
|
||||
// ---
|
||||
logsMaxDays := app.Settings().Logs.MaxDays
|
||||
now := time.Now()
|
||||
lastLogsDeletedAt := cast.ToTime(app.Store().Get("lastLogsDeletedAt"))
|
||||
daysDiff := now.Sub(lastLogsDeletedAt).Hours() * 24
|
||||
if daysDiff > float64(logsMaxDays) {
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(now.AddDate(0, 0, -1*logsMaxDays))
|
||||
if deleteErr == nil {
|
||||
app.Store().Set("lastLogsDeletedAt", now)
|
||||
} else {
|
||||
log.Println("Logs delete failed", deleteErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
handler.WriteAll(ctx)
|
||||
case <-ticker.C:
|
||||
handler.WriteAll(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
app.logger = slog.New(handler)
|
||||
|
||||
app.OnTerminate().PreAdd(func(e *TerminateEvent) error {
|
||||
ticker.Stop()
|
||||
done <- true
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// Deprecated: Replaced with StoreKeyActiveBackup.
|
||||
const CacheKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
const StoreKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
// If name is empty, it will be autogenerated.
|
||||
// If backup with the same name exists, the new backup file will replace it.
|
||||
//
|
||||
// The backup is executed within a transaction, meaning that new writes
|
||||
// will be temporary "blocked" until the backup file is generated.
|
||||
//
|
||||
// To safely perform the backup, it is recommended to have free disk space
|
||||
// for at least 2x the size of the pb_data directory.
|
||||
//
|
||||
// By default backups are stored in pb_data/backups
|
||||
// (the backups directory itself is excluded from the generated backup).
|
||||
//
|
||||
// When using S3 storage for the uploaded collection files, you have to
|
||||
// take care manually to backup those since they are not part of the pb_data.
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = app.generateBackupName("pb_backup_")
|
||||
}
|
||||
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
// root dir entries to exclude from the backup generation
|
||||
exclude := []string{LocalBackupsDirName, LocalTempDirName}
|
||||
|
||||
// make sure that the special temp directory exists
|
||||
// note: it needs to be inside the current pb_data to avoid "cross-device link" errors
|
||||
localTempDir := filepath.Join(app.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// Archive pb_data in a temp directory, exluding the "backups" and the temp dirs.
|
||||
//
|
||||
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
|
||||
// ---
|
||||
tempPath := filepath.Join(localTempDir, "pb_backup_"+security.PseudorandomString(4))
|
||||
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// @todo consider experimenting with temp switching the readonly pragma after the db interface change
|
||||
return archive.Create(app.DataDir(), tempPath, exclude...)
|
||||
})
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
// Persist the backup in the backups filesystem.
|
||||
// ---
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
file, err := filesystem.NewFileFromPath(tempPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.OriginalName = name
|
||||
file.Name = file.OriginalName
|
||||
|
||||
if err := fsys.UploadFile(file, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores the backup with the specified name and restarts
|
||||
// the current running application process.
|
||||
//
|
||||
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
|
||||
//
|
||||
// To safely perform the restore it is recommended to have free disk space
|
||||
// for at least 2x the size of the restored pb_data backup.
|
||||
//
|
||||
// The performed steps are:
|
||||
//
|
||||
// 1. Download the backup with the specified name in a temp location
|
||||
// (this is in case of S3; otherwise it creates a temp copy of the zip)
|
||||
//
|
||||
// 2. Extract the backup in a temp directory inside the app "pb_data"
|
||||
// (eg. "pb_data/.pb_temp_to_delete/pb_restore").
|
||||
//
|
||||
// 3. Move the current app "pb_data" content (excluding the local backups and the special temp dir)
|
||||
// under another temp sub dir that will be deleted on the next app start up
|
||||
// (eg. "pb_data/.pb_temp_to_delete/old_pb_data").
|
||||
// This is because on some environments it may not be allowed
|
||||
// to delete the currently open "pb_data" files.
|
||||
//
|
||||
// 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).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("restore is not supported on windows")
|
||||
}
|
||||
|
||||
if app.Store().Has(StoreKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Store().Set(StoreKeyActiveBackup, name)
|
||||
defer app.Store().Remove(StoreKeyActiveBackup)
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(ctx)
|
||||
|
||||
// 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(app.DataDir(), LocalTempDirName)
|
||||
if err := os.MkdirAll(localTempDir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create a temp dir: %w", err)
|
||||
}
|
||||
|
||||
// create a temp zip file from the blob.Reader and try to extract it
|
||||
tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tempZip.Name())
|
||||
|
||||
if _, err := io.Copy(tempZip, br); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(4))
|
||||
defer os.RemoveAll(extractedDataDir)
|
||||
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure that 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 {
|
||||
app.Logger().Debug(
|
||||
"[RestoreBackup] Failed to remove the temp zip backup file",
|
||||
slog.String("file", tempZip.Name()),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
// root dir entries to exclude from the backup restore
|
||||
exclude := []string{LocalBackupsDirName, LocalTempDirName}
|
||||
|
||||
// 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(app.DataDir(), oldTempDataDir, 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, app.DataDir(), exclude...); err != nil {
|
||||
return fmt.Errorf("failed to move the extracted archive content to pb_data: %w", err)
|
||||
}
|
||||
|
||||
revertDataDirChanges := func() error {
|
||||
if err := osutils.MoveDirContent(app.DataDir(), extractedDataDir, exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
|
||||
}
|
||||
|
||||
if err := osutils.MoveDirContent(oldTempDataDir, app.DataDir(), exclude...); err != nil {
|
||||
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// restart the app
|
||||
if err := app.Restart(); err != nil {
|
||||
if revertErr := revertDataDirChanges(); revertErr != nil {
|
||||
panic(revertErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to restart the app process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initAutobackupHooks registers the autobackup app serve hooks.
|
||||
func (app *BaseApp) initAutobackupHooks() error {
|
||||
c := cron.New()
|
||||
isServe := false
|
||||
|
||||
loadJob := func() {
|
||||
c.Stop()
|
||||
|
||||
// make sure that app.Settings() is always up to date
|
||||
//
|
||||
// @todo remove with the refactoring as core.App and daos.Dao will be one.
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to get the latest app settings",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
if rawSchedule == "" || !isServe || !app.IsBootstrapped() {
|
||||
return
|
||||
}
|
||||
|
||||
c.Add("@autobackup", rawSchedule, func() {
|
||||
const autoPrefix = "@auto_pb_backup_"
|
||||
|
||||
name := app.generateBackupName(autoPrefix)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to create backup",
|
||||
slog.String("name", name),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
if maxKeep == 0 {
|
||||
return // no explicit limit
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to initialize the backup filesystem",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to list autogenerated backups",
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if maxKeep >= len(files) {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
// sort desc
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// keep only the most recent n auto backup files
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil {
|
||||
app.Logger().Debug(
|
||||
"[Backup cron] Failed to remove old autogenerated backup",
|
||||
slog.String("key", f.Key),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// restart the ticker
|
||||
c.Start()
|
||||
}
|
||||
|
||||
// load on app serve
|
||||
app.OnBeforeServe().Add(func(e *ServeEvent) error {
|
||||
isServe = true
|
||||
loadJob()
|
||||
return nil
|
||||
})
|
||||
|
||||
// stop the ticker on app termination
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
c.Stop()
|
||||
return nil
|
||||
})
|
||||
|
||||
// reload on app settings change
|
||||
app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
|
||||
p := e.Model.(*models.Param)
|
||||
if p == nil || p.Key != models.ParamAppSettings {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *BaseApp) generateBackupName(prefix string) string {
|
||||
appName := inflector.Snakecase(app.Settings().Meta.AppName)
|
||||
if len(appName) > 50 {
|
||||
appName = appName[:50]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s%s_%s.zip",
|
||||
prefix,
|
||||
appName,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// set some long app name with spaces and special characters
|
||||
app.Settings().Meta.AppName = "test @! " + strings.Repeat("a", 100)
|
||||
|
||||
expectedAppNamePrefix := "test_" + strings.Repeat("a", 45)
|
||||
|
||||
// test pending error
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// create with auto generated name
|
||||
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
||||
t.Fatal("Failed to create a backup with autogenerated name")
|
||||
}
|
||||
|
||||
// create with custom name
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create a backup with custom name")
|
||||
}
|
||||
|
||||
// create new with the same name (aka. replace)
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create and replace a backup with the same name")
|
||||
}
|
||||
|
||||
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
|
||||
|
||||
entries, err := os.ReadDir(backupsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedFiles := []string{
|
||||
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip$`,
|
||||
`^pb_backup_` + expectedAppNamePrefix + `_\w+\.zip.attrs$`,
|
||||
"custom",
|
||||
"custom.attrs",
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedFiles) {
|
||||
names := getEntryNames(entries)
|
||||
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
|
||||
}
|
||||
|
||||
for i, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
|
||||
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), ".attrs") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(backupsDir, entry.Name())
|
||||
|
||||
if err := verifyBackupContent(app, path); err != nil {
|
||||
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create a initial test backup to ensure that there are at least 1
|
||||
// backup file and that the generated zip doesn't contain the backups dir
|
||||
if err := app.CreateBackup(context.Background(), "initial"); err != nil {
|
||||
t.Fatal("Failed to create test initial backup")
|
||||
}
|
||||
|
||||
// create test backup
|
||||
if err := app.CreateBackup(context.Background(), "test"); err != nil {
|
||||
t.Fatal("Failed to create test backup")
|
||||
}
|
||||
|
||||
// test pending error
|
||||
app.Store().Set(core.StoreKeyActiveBackup, "")
|
||||
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Store().Remove(core.StoreKeyActiveBackup)
|
||||
|
||||
// missing backup
|
||||
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
||||
t.Fatal("Expected missing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func verifyBackupContent(app core.App, path string) error {
|
||||
dir, err := os.MkdirTemp("", "backup_test")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if err := archive.Extract(path, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedRootEntries := []string{
|
||||
"storage",
|
||||
"data.db",
|
||||
"data.db-shm",
|
||||
"data.db-wal",
|
||||
"logs.db",
|
||||
"logs.db-shm",
|
||||
"logs.db-wal",
|
||||
".gitignore",
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedRootEntries) {
|
||||
names := getEntryNames(entries)
|
||||
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
|
||||
return fmt.Errorf("Didn't expect %q entry", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEntryNames(entries []fs.DirEntry) []string {
|
||||
names := make([]string, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
names[i] = entry.Name()
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestBaseAppRefreshSettings(t *testing.T) {
|
||||
// check if the new settings are saved in the db
|
||||
app.ResetEventCalls()
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal("Failed to refresh the settings after delete")
|
||||
t.Fatalf("Failed to refresh the settings after delete: %v", err)
|
||||
}
|
||||
testEventCalls(t, app, map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
|
||||
+349
-35
@@ -1,20 +1,30 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/migrations/logs"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/migrate"
|
||||
)
|
||||
|
||||
func TestNewBaseApp(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "test_env",
|
||||
IsDebug: true,
|
||||
IsDev: true,
|
||||
})
|
||||
|
||||
if app.dataDir != testDataDir {
|
||||
@@ -25,12 +35,12 @@ func TestNewBaseApp(t *testing.T) {
|
||||
t.Fatalf("expected encryptionEnv test_env, got %q", app.dataDir)
|
||||
}
|
||||
|
||||
if !app.isDebug {
|
||||
t.Fatalf("expected isDebug true, got %v", app.isDebug)
|
||||
if !app.isDev {
|
||||
t.Fatalf("expected isDev true, got %v", app.isDev)
|
||||
}
|
||||
|
||||
if app.cache == nil {
|
||||
t.Fatal("expected cache to be set, got nil")
|
||||
if app.store == nil {
|
||||
t.Fatal("expected store to be set, got nil")
|
||||
}
|
||||
|
||||
if app.settings == nil {
|
||||
@@ -46,10 +56,9 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -57,7 +66,6 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Didn't expect the application to be bootstrapped.")
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -106,6 +114,14 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
t.Fatal("Expected app.settings to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if app.logger == nil {
|
||||
t.Fatal("Expected app.logger to be initialized, got nil.")
|
||||
}
|
||||
|
||||
if _, ok := app.logger.Handler().(*logger.BatchHandler); !ok {
|
||||
t.Fatal("Expected app.logger handler to be initialized.")
|
||||
}
|
||||
|
||||
// reset
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -118,20 +134,16 @@ func TestBaseAppBootstrap(t *testing.T) {
|
||||
if app.logsDao != nil {
|
||||
t.Fatalf("Expected app.logsDao to be nil, got %v.", app.logsDao)
|
||||
}
|
||||
|
||||
if app.settings != nil {
|
||||
t.Fatalf("Expected app.settings to be nil, got %v.", app.settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppGetters(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
app := NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
IsDev: true,
|
||||
})
|
||||
defer app.ResetBootstrapState()
|
||||
|
||||
@@ -163,16 +175,20 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
t.Fatalf("Expected app.EncryptionEnv %v, got %v", app.EncryptionEnv(), app.encryptionEnv)
|
||||
}
|
||||
|
||||
if app.isDebug != app.IsDebug() {
|
||||
t.Fatalf("Expected app.IsDebug %v, got %v", app.IsDebug(), app.isDebug)
|
||||
if app.isDev != app.IsDev() {
|
||||
t.Fatalf("Expected app.IsDev %v, got %v", app.IsDev(), app.isDev)
|
||||
}
|
||||
|
||||
if app.settings != app.Settings() {
|
||||
t.Fatalf("Expected app.Settings %v, got %v", app.Settings(), app.settings)
|
||||
}
|
||||
|
||||
if app.cache != app.Cache() {
|
||||
t.Fatalf("Expected app.Cache %v, got %v", app.Cache(), app.cache)
|
||||
if app.store != app.Store() {
|
||||
t.Fatalf("Expected app.Store %v, got %v", app.Store(), app.store)
|
||||
}
|
||||
|
||||
if app.logger != app.Logger() {
|
||||
t.Fatalf("Expected app.Logger %v, got %v", app.Logger(), app.logger)
|
||||
}
|
||||
|
||||
if app.subscriptionsBroker != app.SubscriptionsBroker() {
|
||||
@@ -185,14 +201,11 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewMailClient(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
client1 := app.NewMailClient()
|
||||
if val, ok := client1.(*mailer.Sendmail); !ok {
|
||||
@@ -208,14 +221,11 @@ func TestBaseAppNewMailClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
const testDataDir = "./pb_base_app_test_data_dir/"
|
||||
defer os.RemoveAll(testDataDir)
|
||||
|
||||
app := NewBaseApp(&BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
EncryptionEnv: "pb_test_env",
|
||||
IsDebug: false,
|
||||
})
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewFilesystem()
|
||||
@@ -236,3 +246,307 @@ func TestBaseAppNewFilesystem(t *testing.T) {
|
||||
t.Fatalf("Expected nil s3 filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// local
|
||||
local, localErr := app.NewBackupsFilesystem()
|
||||
if localErr != nil {
|
||||
t.Fatal(localErr)
|
||||
}
|
||||
if local == nil {
|
||||
t.Fatal("Expected local backups filesystem instance, got nil")
|
||||
}
|
||||
|
||||
// misconfigured s3
|
||||
app.Settings().Backups.S3.Enabled = true
|
||||
s3, s3Err := app.NewBackupsFilesystem()
|
||||
if s3Err == nil {
|
||||
t.Fatal("Expected S3 error, got nil")
|
||||
}
|
||||
if s3 != nil {
|
||||
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerWrites(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
threshold := 200
|
||||
|
||||
totalLogs := func(app App, t *testing.T) int {
|
||||
var total int
|
||||
|
||||
err := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch total logs: %v", err)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// disabled logs retention
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 0
|
||||
|
||||
for i := 0; i < threshold+1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// test batch logs writes
|
||||
{
|
||||
app.Settings().Logs.MaxDays = 1
|
||||
|
||||
for i := 0; i < threshold-1; i++ {
|
||||
app.Logger().Error("test")
|
||||
}
|
||||
|
||||
if total := totalLogs(app, t); total != 0 {
|
||||
t.Fatalf("Expected no logs, got %d", total)
|
||||
}
|
||||
|
||||
// should trigger batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
// should be added for the next batch write
|
||||
app.Logger().Error("test")
|
||||
|
||||
if total := totalLogs(app, t); total != threshold {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold, total)
|
||||
}
|
||||
|
||||
// wait for ~3 secs to check the timer trigger
|
||||
time.Sleep(3200 * time.Millisecond)
|
||||
if total := totalLogs(app, t); total != threshold+1 {
|
||||
t.Fatalf("Expected %d logs, got %d", threshold+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppRefreshSettingsLoggerMinLevelEnabled(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
level int
|
||||
// level->enabled map
|
||||
expectations map[int]bool
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
4,
|
||||
map[int]bool{
|
||||
3: false,
|
||||
4: true,
|
||||
5: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app.isDev = s.isDev
|
||||
|
||||
app.Settings().Logs.MinLevel = s.level
|
||||
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatalf("Failed to refresh app settings: %v", err)
|
||||
}
|
||||
|
||||
for level, enabled := range s.expectations {
|
||||
if v := handler.Enabled(nil, slog.Level(level)); v != enabled {
|
||||
t.Fatalf("Expected level %d Enabled() to be %v, got %v", level, enabled, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAppLoggerLevelDevPrint(t *testing.T) {
|
||||
app, cleanup, err := initTestBaseApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
testLogLevel := 4
|
||||
|
||||
app.Settings().Logs.MinLevel = testLogLevel
|
||||
if err := app.Dao().SaveSettings(app.Settings()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
isDev bool
|
||||
levels []int
|
||||
printedLevels []int
|
||||
persistedLevels []int
|
||||
}{
|
||||
{
|
||||
"dev mode",
|
||||
true,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
{
|
||||
"nondev mode",
|
||||
false,
|
||||
[]int{testLogLevel - 1, testLogLevel, testLogLevel + 1},
|
||||
[]int{},
|
||||
[]int{testLogLevel, testLogLevel + 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
var printedLevels []int
|
||||
var persistedLevels []int
|
||||
|
||||
app.isDev = s.isDev
|
||||
|
||||
// trigger slog handler min level refresh
|
||||
if err := app.RefreshSettings(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// track printed logs
|
||||
originalPrintLog := printLog
|
||||
defer func() {
|
||||
printLog = originalPrintLog
|
||||
}()
|
||||
printLog = func(log *logger.Log) {
|
||||
printedLevels = append(printedLevels, int(log.Level))
|
||||
}
|
||||
|
||||
// track persisted logs
|
||||
app.LogsDao().AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
l, ok := m.(*models.Log)
|
||||
if ok {
|
||||
persistedLevels = append(persistedLevels, l.Level)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// write and persist logs
|
||||
for _, l := range s.levels {
|
||||
app.Logger().Log(nil, slog.Level(l), "test")
|
||||
}
|
||||
handler, ok := app.Logger().Handler().(*logger.BatchHandler)
|
||||
if !ok {
|
||||
t.Fatalf("Expected BatchHandler, got %v", app.Logger().Handler())
|
||||
}
|
||||
if err := handler.WriteAll(nil); err != nil {
|
||||
t.Fatalf("Failed to write all logs: %v", err)
|
||||
}
|
||||
|
||||
// check persisted log levels
|
||||
if len(s.persistedLevels) != len(persistedLevels) {
|
||||
t.Fatalf("Expected persisted levels \n%v\ngot\n%v", s.persistedLevels, persistedLevels)
|
||||
}
|
||||
for _, l := range persistedLevels {
|
||||
if !list.ExistInSlice(l, s.persistedLevels) {
|
||||
t.Fatalf("Missing expected persisted level %v in %v", l, persistedLevels)
|
||||
}
|
||||
}
|
||||
|
||||
// check printed log levels
|
||||
if len(s.printedLevels) != len(printedLevels) {
|
||||
t.Fatalf("Expected printed levels \n%v\ngot\n%v", s.printedLevels, printedLevels)
|
||||
}
|
||||
for _, l := range printedLevels {
|
||||
if !list.ExistInSlice(l, s.printedLevels) {
|
||||
t.Fatalf("Missing expected printed level %v in %v", l, printedLevels)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// note: make sure to call `defer cleanup()` when the app is no longer needed.
|
||||
func initTestBaseApp() (app *BaseApp, cleanup func(), err error) {
|
||||
testDataDir, err := os.MkdirTemp("", "test_base_app")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cleanup = func() {
|
||||
os.RemoveAll(testDataDir)
|
||||
}
|
||||
|
||||
app = NewBaseApp(BaseAppConfig{
|
||||
DataDir: testDataDir,
|
||||
})
|
||||
|
||||
initErr := func() error {
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
return fmt.Errorf("bootstrap error: %w", err)
|
||||
}
|
||||
|
||||
logsRunner, err := migrate.NewRunner(app.LogsDB(), logs.LogsMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := logsRunner.Up(); err != nil {
|
||||
return fmt.Errorf("logsRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
dataRunner, err := migrate.NewRunner(app.DB(), migrations.AppMigrations)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logsRunner error: %w", err)
|
||||
}
|
||||
if _, err := dataRunner.Up(); err != nil {
|
||||
return fmt.Errorf("dataRunner migrations execution error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if initErr != nil {
|
||||
cleanup()
|
||||
return nil, nil, initErr
|
||||
}
|
||||
|
||||
return app, cleanup, nil
|
||||
}
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
func initPragmas(db *dbx.DB) error {
|
||||
// note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection
|
||||
_, err := db.NewQuery(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = TRUE;
|
||||
`).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
+36
-8
@@ -3,18 +3,46 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/pocketbase/dbx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func init() {
|
||||
// Registers the sqlite3 driver with a ConnectHook so that we can
|
||||
// initialize the default PRAGMAs.
|
||||
//
|
||||
// Note 1: we don't define the PRAGMA as part of the dsn string
|
||||
// because not all pragmas are available.
|
||||
//
|
||||
// Note 2: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection.
|
||||
sql.Register("pb_sqlite3",
|
||||
&sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
_, err := conn.Exec(`
|
||||
PRAGMA busy_timeout = 10000;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA journal_size_limit = 200000000;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -16000;
|
||||
`, nil)
|
||||
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
|
||||
}
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("pb_sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -8,13 +8,13 @@ import (
|
||||
)
|
||||
|
||||
func connectDB(dbPath string) (*dbx.DB, error) {
|
||||
db, err := dbx.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Note: the busy_timeout pragma must be first because
|
||||
// the connection needs to be set to block on busy before WAL mode
|
||||
// is set in case it hasn't been already set by another connection.
|
||||
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-16000)"
|
||||
|
||||
if err := initPragmas(db); err != nil {
|
||||
db.Close()
|
||||
db, err := dbx.Open("sqlite", dbPath+pragmas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+51
-22
@@ -1,6 +1,10 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
@@ -11,10 +15,30 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
var (
|
||||
_ hook.Tagger = (*BaseModelEvent)(nil)
|
||||
_ hook.Tagger = (*BaseCollectionEvent)(nil)
|
||||
)
|
||||
|
||||
type BaseModelEvent struct {
|
||||
Model models.Model
|
||||
}
|
||||
|
||||
func (e *BaseModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
}
|
||||
|
||||
type BaseCollectionEvent struct {
|
||||
Collection *models.Collection
|
||||
}
|
||||
@@ -45,9 +69,16 @@ type BootstrapEvent struct {
|
||||
App App
|
||||
}
|
||||
|
||||
type TerminateEvent struct {
|
||||
App App
|
||||
IsRestart bool
|
||||
}
|
||||
|
||||
type ServeEvent struct {
|
||||
App App
|
||||
Router *echo.Echo
|
||||
App App
|
||||
Router *echo.Echo
|
||||
Server *http.Server
|
||||
CertManager *autocert.Manager
|
||||
}
|
||||
|
||||
type ApiErrorEvent struct {
|
||||
@@ -59,23 +90,10 @@ type ApiErrorEvent struct {
|
||||
// Model DAO events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var _ hook.Tagger = (*ModelEvent)(nil)
|
||||
|
||||
type ModelEvent struct {
|
||||
Dao *daos.Dao
|
||||
Model models.Model
|
||||
}
|
||||
BaseModelEvent
|
||||
|
||||
func (e *ModelEvent) Tags() []string {
|
||||
if e.Model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r, ok := e.Model.(*models.Record); ok && r.Collection() != nil {
|
||||
return []string{r.Collection().Id, r.Collection().Name}
|
||||
}
|
||||
|
||||
return []string{e.Model.TableName()}
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -105,6 +123,7 @@ type MailerAdminEvent struct {
|
||||
type RealtimeConnectEvent struct {
|
||||
HttpContext echo.Context
|
||||
Client subscriptions.Client
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
type RealtimeDisconnectEvent struct {
|
||||
@@ -206,9 +225,12 @@ type RecordAuthWithPasswordEvent struct {
|
||||
type RecordAuthWithOAuth2Event struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
HttpContext echo.Context
|
||||
ProviderName string
|
||||
ProviderClient auth.Provider
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
IsNewRecord bool
|
||||
}
|
||||
|
||||
type RecordAuthRefreshEvent struct {
|
||||
@@ -377,6 +399,13 @@ type CollectionsImportEvent struct {
|
||||
// File API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type FileTokenEvent struct {
|
||||
BaseModelEvent
|
||||
|
||||
HttpContext echo.Context
|
||||
Token string
|
||||
}
|
||||
|
||||
type FileDownloadEvent struct {
|
||||
BaseCollectionEvent
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/pocketbase/tools/logger"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
var cachedColors = store.New[*color.Color](nil)
|
||||
|
||||
// getColor returns [color.Color] object and cache it (if not already).
|
||||
func getColor(attrs ...color.Attribute) (c *color.Color) {
|
||||
cacheKey := fmt.Sprint(attrs)
|
||||
if c = cachedColors.Get(cacheKey); c == nil {
|
||||
c = color.New(attrs...)
|
||||
cachedColors.Set(cacheKey, c)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// printLog prints the provided log to the stderr.
|
||||
// (note: defined as variable to overwriting in the tests)
|
||||
var printLog = func(log *logger.Log) {
|
||||
var str strings.Builder
|
||||
|
||||
switch log.Level {
|
||||
case slog.LevelDebug:
|
||||
str.WriteString(getColor(color.Bold, color.FgHiBlack).Sprint("DEBUG "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelInfo:
|
||||
str.WriteString(getColor(color.Bold, color.FgWhite).Sprint("INFO "))
|
||||
str.WriteString(getColor(color.FgWhite).Sprint(log.Message))
|
||||
case slog.LevelWarn:
|
||||
str.WriteString(getColor(color.Bold, color.FgYellow).Sprint("WARN "))
|
||||
str.WriteString(getColor(color.FgYellow).Sprint(log.Message))
|
||||
case slog.LevelError:
|
||||
str.WriteString(getColor(color.Bold, color.FgRed).Sprint("ERROR "))
|
||||
str.WriteString(getColor(color.FgRed).Sprint(log.Message))
|
||||
default:
|
||||
str.WriteString(getColor(color.Bold, color.FgCyan).Sprintf("[%d] ", log.Level))
|
||||
str.WriteString(getColor(color.FgCyan).Sprint(log.Message))
|
||||
}
|
||||
|
||||
str.WriteString("\n")
|
||||
|
||||
if v, ok := log.Data["type"]; ok && cast.ToString(v) == "request" {
|
||||
padding := 0
|
||||
keys := []string{"error", "details"}
|
||||
for _, k := range keys {
|
||||
if v := log.Data[k]; v != nil {
|
||||
str.WriteString(getColor(color.FgHiRed).Sprintf("%s└─ %v", strings.Repeat(" ", padding), v))
|
||||
str.WriteString("\n")
|
||||
padding += 3
|
||||
}
|
||||
}
|
||||
} else if len(log.Data) > 0 {
|
||||
str.WriteString(getColor(color.FgHiBlack).Sprintf("└─ %v", log.Data))
|
||||
str.WriteString("\n")
|
||||
}
|
||||
|
||||
fmt.Print(str.String())
|
||||
}
|
||||
+2
-2
@@ -46,9 +46,9 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindAdminByToken finds the admin associated with the provided JWT token.
|
||||
// FindAdminByToken finds the admin associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
// Returns an error if the JWT is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
// @todo consider caching the unverified claims
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -20,6 +22,8 @@ func TestAdminQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -47,6 +51,8 @@ func TestFindAdminById(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -76,6 +82,8 @@ func TestFindAdminByEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAdminByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -131,6 +139,8 @@ func TestFindAdminByToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTotalAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -155,6 +165,8 @@ func TestTotalAdmins(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAdminEmailUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -180,6 +192,8 @@ func TestIsAdminEmailUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -225,6 +239,8 @@ func TestDeleteAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+140
-95
@@ -5,6 +5,7 @@ package daos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -17,7 +18,7 @@ func New(db dbx.Builder) *Dao {
|
||||
return NewMultiDB(db, db)
|
||||
}
|
||||
|
||||
// New creates a new Dao instance with the provided dedicated
|
||||
// NewMultiDB creates a new Dao instance with the provided dedicated
|
||||
// async and sync db builders.
|
||||
func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
return &Dao{
|
||||
@@ -29,7 +30,8 @@ func NewMultiDB(concurrentDB, nonconcurrentDB dbx.Builder) *Dao {
|
||||
}
|
||||
|
||||
// Dao handles various db operations.
|
||||
// Think of Dao as a repository and service layer in one.
|
||||
//
|
||||
// You can think of Dao as a repository and service layer in one.
|
||||
type Dao struct {
|
||||
// in a transaction both refer to the same *dbx.TX instance
|
||||
concurrentDB dbx.Builder
|
||||
@@ -43,12 +45,13 @@ type Dao struct {
|
||||
// This field has no effect if an explicit query context is already specified.
|
||||
ModelQueryTimeout time.Duration
|
||||
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model)
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model)
|
||||
// write hooks
|
||||
BeforeCreateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterCreateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeUpdateFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterUpdateFunc func(eventDao *Dao, m models.Model) error
|
||||
BeforeDeleteFunc func(eventDao *Dao, m models.Model, action func() error) error
|
||||
AfterDeleteFunc func(eventDao *Dao, m models.Model) error
|
||||
}
|
||||
|
||||
// DB returns the default dao db builder (*dbx.DB or *dbx.TX).
|
||||
@@ -74,6 +77,28 @@ func (dao *Dao) NonconcurrentDB() dbx.Builder {
|
||||
return dao.nonconcurrentDB
|
||||
}
|
||||
|
||||
// Clone returns a new Dao with the same configuration options as the current one.
|
||||
func (dao *Dao) Clone() *Dao {
|
||||
clone := *dao
|
||||
|
||||
return &clone
|
||||
}
|
||||
|
||||
// WithoutHooks returns a new Dao with the same configuration options
|
||||
// as the current one, but without create/update/delete hooks.
|
||||
func (dao *Dao) WithoutHooks() *Dao {
|
||||
clone := dao.Clone()
|
||||
|
||||
clone.BeforeCreateFunc = nil
|
||||
clone.AfterCreateFunc = nil
|
||||
clone.BeforeUpdateFunc = nil
|
||||
clone.AfterUpdateFunc = nil
|
||||
clone.BeforeDeleteFunc = nil
|
||||
clone.AfterDeleteFunc = nil
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// ModelQuery creates a new preconfigured select query with preset
|
||||
// SELECT, FROM and other common fields based on the provided model.
|
||||
func (dao *Dao) ModelQuery(m models.Model) *dbx.SelectQuery {
|
||||
@@ -110,6 +135,8 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
// ---
|
||||
// create a new dao with the same hooks to avoid semaphore deadlock when nesting
|
||||
txDao := New(txOrDB)
|
||||
txDao.MaxLockRetries = dao.MaxLockRetries
|
||||
txDao.ModelQueryTimeout = dao.ModelQueryTimeout
|
||||
txDao.BeforeCreateFunc = dao.BeforeCreateFunc
|
||||
txDao.BeforeUpdateFunc = dao.BeforeUpdateFunc
|
||||
txDao.BeforeDeleteFunc = dao.BeforeDeleteFunc
|
||||
@@ -125,56 +152,69 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
|
||||
txDao := New(tx)
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m)
|
||||
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeCreateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m)
|
||||
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeUpdateFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
if dao.BeforeDeleteFunc != nil {
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m)
|
||||
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model, action func() error) error {
|
||||
return dao.BeforeDeleteFunc(eventDao, m, action)
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if dao.AfterDeleteFunc != nil {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
|
||||
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) error {
|
||||
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fn(txDao)
|
||||
})
|
||||
|
||||
if txError == nil {
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
for _, call := range afterCalls {
|
||||
switch call.Action {
|
||||
case "create":
|
||||
dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
}
|
||||
if txError != nil {
|
||||
return txError
|
||||
}
|
||||
|
||||
return txError
|
||||
// execute after event calls on successful transaction
|
||||
// (note: using the non-transaction dao to allow following queries in the after hooks)
|
||||
var errs []error
|
||||
for _, call := range afterCalls {
|
||||
var err error
|
||||
switch call.Action {
|
||||
case "create":
|
||||
err = dao.AfterCreateFunc(dao, call.Model)
|
||||
case "update":
|
||||
err = dao.AfterUpdateFunc(dao, call.Model)
|
||||
case "delete":
|
||||
err = dao.AfterDeleteFunc(dao, call.Model)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("after transaction errors: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("failed to start transaction (unknown dao.NonconcurrentDB() instance)")
|
||||
@@ -187,25 +227,30 @@ func (dao *Dao) Delete(m models.Model) error {
|
||||
}
|
||||
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
if err := retryDao.BeforeDeleteFunc(retryDao, m); err != nil {
|
||||
action := func() error {
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.NonconcurrentDB().Model(m).Delete(); err != nil {
|
||||
return err
|
||||
if retryDao.BeforeDeleteFunc != nil {
|
||||
return retryDao.BeforeDeleteFunc(retryDao, m, action)
|
||||
}
|
||||
|
||||
if retryDao.AfterDeleteFunc != nil {
|
||||
retryDao.AfterDeleteFunc(retryDao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
})
|
||||
}
|
||||
|
||||
// Save upserts (update or create if primary key is not set) the provided model.
|
||||
// Save persists the provided model in the database.
|
||||
//
|
||||
// If m.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a model for update you can use m.MarkAsNotNew().
|
||||
func (dao *Dao) Save(m models.Model) error {
|
||||
if m.IsNew() {
|
||||
return dao.lockRetry(func(retryDao *Dao) error {
|
||||
@@ -229,35 +274,35 @@ func (dao *Dao) update(m models.Model) error {
|
||||
|
||||
m.RefreshUpdated()
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
return dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeUpdateFunc != nil {
|
||||
if err := dao.BeforeUpdateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeUpdateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
|
||||
_, err := dao.NonconcurrentDB().Update(
|
||||
m.TableName(),
|
||||
dataMap,
|
||||
dbx.HashExp{"id": m.GetId()},
|
||||
).Execute()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if dao.AfterUpdateFunc != nil {
|
||||
dao.AfterUpdateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) create(m models.Model) error {
|
||||
@@ -277,36 +322,36 @@ func (dao *Dao) create(m models.Model) error {
|
||||
m.RefreshUpdated()
|
||||
}
|
||||
|
||||
action := func() error {
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
return dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.BeforeCreateFunc != nil {
|
||||
if err := dao.BeforeCreateFunc(dao, m); err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.BeforeCreateFunc(dao, m, action)
|
||||
}
|
||||
|
||||
if v, ok := any(m).(models.ColumnValueMapper); ok {
|
||||
dataMap := v.ColumnValueMap()
|
||||
if _, ok := dataMap["id"]; !ok {
|
||||
dataMap["id"] = m.GetId()
|
||||
}
|
||||
|
||||
_, err := dao.NonconcurrentDB().Insert(m.TableName(), dataMap).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := dao.NonconcurrentDB().Model(m).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// clears the "new" model flag
|
||||
m.MarkAsNotNew()
|
||||
|
||||
if dao.AfterCreateFunc != nil {
|
||||
dao.AfterCreateFunc(dao, m)
|
||||
}
|
||||
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
func (dao *Dao) lockRetry(op func(retryDao *Dao) error) error {
|
||||
|
||||
+9
-1
@@ -2,6 +2,9 @@ package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,9 +26,14 @@ func execLockRetry(timeout time.Duration, maxRetries int) dbx.ExecHookFunc {
|
||||
q.WithContext(cancelCtx)
|
||||
}
|
||||
|
||||
return baseLockRetry(func(attempt int) error {
|
||||
execErr := baseLockRetry(func(attempt int) error {
|
||||
return op()
|
||||
}, maxRetries)
|
||||
if execErr != nil && !errors.Is(execErr, sql.ErrNoRows) {
|
||||
execErr = fmt.Errorf("%w; failed query: %s", execErr, q.SQL())
|
||||
}
|
||||
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
)
|
||||
|
||||
func TestGetDefaultRetryInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if i := getDefaultRetryInterval(-1); i.Milliseconds() != 1000 {
|
||||
t.Fatalf("Expected 1000ms, got %v", i)
|
||||
}
|
||||
@@ -20,6 +22,8 @@ func TestGetDefaultRetryInterval(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBaseLockRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
err error
|
||||
failUntilAttempt int
|
||||
|
||||
+194
-30
@@ -40,6 +40,161 @@ func TestNewMultiDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoClone(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
hookCalls := map[string]int{}
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := dao.Clone()
|
||||
clone.MaxLockRetries = 3
|
||||
clone.ModelQueryTimeout = 4
|
||||
clone.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["NewAfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
if dao.MaxLockRetries == clone.MaxLockRetries {
|
||||
t.Fatal("Expected different MaxLockRetries")
|
||||
}
|
||||
|
||||
if dao.ModelQueryTimeout == clone.ModelQueryTimeout {
|
||||
t.Fatal("Expected different ModelQueryTimeout")
|
||||
}
|
||||
|
||||
emptyAction := func() error { return nil }
|
||||
|
||||
// trigger hooks
|
||||
dao.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
dao.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
dao.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
dao.AfterDeleteFunc(nil, nil)
|
||||
dao.AfterUpdateFunc(nil, nil)
|
||||
dao.AfterCreateFunc(nil, nil)
|
||||
clone.BeforeDeleteFunc(nil, nil, emptyAction)
|
||||
clone.BeforeUpdateFunc(nil, nil, emptyAction)
|
||||
clone.BeforeCreateFunc(nil, nil, emptyAction)
|
||||
clone.AfterDeleteFunc(nil, nil)
|
||||
clone.AfterUpdateFunc(nil, nil)
|
||||
clone.AfterCreateFunc(nil, nil)
|
||||
|
||||
expectations := []struct {
|
||||
hook string
|
||||
total int
|
||||
}{
|
||||
{"BeforeDeleteFunc", 2},
|
||||
{"BeforeUpdateFunc", 2},
|
||||
{"BeforeCreateFunc", 2},
|
||||
{"AfterDeleteFunc", 2},
|
||||
{"AfterUpdateFunc", 2},
|
||||
{"AfterCreateFunc", 1},
|
||||
{"NewAfterCreateFunc", 1},
|
||||
}
|
||||
|
||||
for _, e := range expectations {
|
||||
if hookCalls[e.hook] != e.total {
|
||||
t.Errorf("Expected %s to be caleed %d", e.hook, e.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoWithoutHooks(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
hookCalls := map[string]int{}
|
||||
|
||||
dao := daos.NewMultiDB(testApp.Dao().ConcurrentDB(), testApp.Dao().NonconcurrentDB())
|
||||
dao.MaxLockRetries = 1
|
||||
dao.ModelQueryTimeout = 2
|
||||
dao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeDeleteFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeUpdateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
hookCalls["BeforeCreateFunc"]++
|
||||
return action()
|
||||
}
|
||||
dao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterDeleteFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterUpdateFunc"]++
|
||||
return nil
|
||||
}
|
||||
dao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
hookCalls["AfterCreateFunc"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
new := dao.WithoutHooks()
|
||||
|
||||
if new.MaxLockRetries != dao.MaxLockRetries {
|
||||
t.Fatalf("Expected MaxLockRetries %d, got %d", new.Clone().MaxLockRetries, dao.MaxLockRetries)
|
||||
}
|
||||
|
||||
if new.ModelQueryTimeout != dao.ModelQueryTimeout {
|
||||
t.Fatalf("Expected ModelQueryTimeout %d, got %d", new.Clone().ModelQueryTimeout, dao.ModelQueryTimeout)
|
||||
}
|
||||
|
||||
if new.BeforeDeleteFunc != nil {
|
||||
t.Fatal("Expected BeforeDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeUpdateFunc != nil {
|
||||
t.Fatal("Expected BeforeUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.BeforeCreateFunc != nil {
|
||||
t.Fatal("Expected BeforeCreateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterDeleteFunc != nil {
|
||||
t.Fatal("Expected AfterDeleteFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterUpdateFunc != nil {
|
||||
t.Fatal("Expected AfterUpdateFunc to be nil")
|
||||
}
|
||||
|
||||
if new.AfterCreateFunc != nil {
|
||||
t.Fatal("Expected AfterCreateFunc to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaoModelQuery(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
@@ -335,12 +490,13 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
retryBeforeCreateHookCalls := 0
|
||||
retryAfterCreateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterCreateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model := &models.Admin{Email: "new@example.com"}
|
||||
@@ -361,7 +517,7 @@ func TestDaoRetryCreate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeCreateHookCalls = 0
|
||||
retryAfterCreateHookCalls = 0
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeCreateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -393,12 +549,13 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
retryBeforeUpdateHookCalls := 0
|
||||
retryAfterUpdateHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterUpdateHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := retryDao.Save(model); err != nil {
|
||||
@@ -418,7 +575,7 @@ func TestDaoRetryUpdate(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeUpdateHookCalls = 0
|
||||
retryAfterUpdateHookCalls = 0
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeUpdateHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -444,12 +601,13 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
retryBeforeDeleteHookCalls := 0
|
||||
retryAfterDeleteHookCalls := 0
|
||||
retryDao := daos.New(testApp.DB())
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("database is locked")
|
||||
}
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
retryDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryAfterDeleteHookCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
model, _ := retryDao.FindAdminByEmail("test@example.com")
|
||||
@@ -470,7 +628,7 @@ func TestDaoRetryDelete(t *testing.T) {
|
||||
// with non-locking error
|
||||
retryBeforeDeleteHookCalls = 0
|
||||
retryAfterDeleteHookCalls = 0
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
retryDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
retryBeforeDeleteHookCalls++
|
||||
return errors.New("non-locking error")
|
||||
}
|
||||
@@ -497,13 +655,13 @@ func TestDaoBeforeHooksError(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_create")
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_update")
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
return errors.New("before_delete")
|
||||
}
|
||||
|
||||
@@ -542,27 +700,30 @@ func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
@@ -630,27 +791,30 @@ func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
|
||||
|
||||
baseDao := testApp.Dao()
|
||||
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeCreateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeUpdateFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model, action func() error) error {
|
||||
beforeDeleteFuncCalls++
|
||||
return nil
|
||||
return action()
|
||||
}
|
||||
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterCreateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterUpdateFuncCalls++
|
||||
return nil
|
||||
}
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
|
||||
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
|
||||
afterDeleteFuncCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
|
||||
|
||||
+148
-12
@@ -1,6 +1,8 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -148,12 +150,20 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
|
||||
}
|
||||
}
|
||||
|
||||
// trigger views resave to check for dependencies
|
||||
if err := txDao.resaveViewsWithChangedSchema(collection.Id); err != nil {
|
||||
return fmt.Errorf("The collection has a view dependency - %w", err)
|
||||
}
|
||||
|
||||
return txDao.Delete(collection)
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCollection upserts the provided Collection model and updates
|
||||
// SaveCollection persists the provided Collection model and updates
|
||||
// its related records table schema.
|
||||
//
|
||||
// If collection.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a collection for update you can use collection.MarkAsNotNew().
|
||||
func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
var oldCollection *models.Collection
|
||||
|
||||
@@ -167,7 +177,7 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
txErr := dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// set default collection type
|
||||
if collection.Type == "" {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
@@ -175,7 +185,9 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
|
||||
switch collection.Type {
|
||||
case models.CollectionTypeView:
|
||||
return txDao.saveViewCollection(collection, oldCollection)
|
||||
if err := txDao.saveViewCollection(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
@@ -183,9 +195,23 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
|
||||
// sync the changes with the related records table
|
||||
return txDao.SyncRecordTableSchema(collection, oldCollection)
|
||||
if err := txDao.SyncRecordTableSchema(collection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// trigger an update for all views with changed schema as a result of the current collection save
|
||||
// (ignoring view errors to allow users to update the query from the UI)
|
||||
dao.resaveViewsWithChangedSchema(collection.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportCollections imports the provided collections list within a single transaction.
|
||||
@@ -237,11 +263,11 @@ func (dao *Dao) ImportCollections(
|
||||
|
||||
// extend existing schema
|
||||
if !deleteMissing {
|
||||
schema, _ := existing.Schema.Clone()
|
||||
schemaClone, _ := existing.Schema.Clone()
|
||||
for _, f := range imported.Schema.Fields() {
|
||||
schema.AddField(f) // add or replace
|
||||
schemaClone.AddField(f) // add or replace
|
||||
}
|
||||
imported.Schema = *schema
|
||||
imported.Schema = *schemaClone
|
||||
}
|
||||
} else {
|
||||
imported.MarkAsNew()
|
||||
@@ -331,8 +357,8 @@ func (dao *Dao) ImportCollections(
|
||||
// - saves the newCollection
|
||||
//
|
||||
// This method returns an error if newCollection is not a "view".
|
||||
func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
if newCollection.IsAuth() {
|
||||
func (dao *Dao) saveViewCollection(newCollection, oldCollection *models.Collection) error {
|
||||
if !newCollection.IsView() {
|
||||
return errors.New("not a view collection")
|
||||
}
|
||||
|
||||
@@ -340,25 +366,135 @@ func (dao *Dao) saveViewCollection(newCollection *models.Collection, oldCollecti
|
||||
query := newCollection.ViewOptions().Query
|
||||
|
||||
// generate collection schema from the query
|
||||
schema, err := txDao.CreateViewSchema(query)
|
||||
viewSchema, err := txDao.CreateViewSchema(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete old renamed view
|
||||
if oldCollection != nil && newCollection.Name != oldCollection.Name {
|
||||
if oldCollection != nil {
|
||||
if err := txDao.DeleteView(oldCollection.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wrap view query if necessary
|
||||
query, err = txDao.normalizeViewQueryId(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize view query id: %w", err)
|
||||
}
|
||||
|
||||
// (re)create the view
|
||||
if err := txDao.SaveView(newCollection.Name, query); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newCollection.Schema = schema
|
||||
newCollection.Schema = viewSchema
|
||||
|
||||
return txDao.Save(newCollection)
|
||||
})
|
||||
}
|
||||
|
||||
// @todo consider removing once custom id types are supported
|
||||
//
|
||||
// normalizeViewQueryId wraps (if necessary) the provided view query
|
||||
// with a subselect to ensure that the id column is a text since
|
||||
// currently we don't support non-string model ids
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3110).
|
||||
func (dao *Dao) normalizeViewQueryId(query string) (string, error) {
|
||||
query = strings.Trim(strings.TrimSpace(query), ";")
|
||||
|
||||
parsed, err := dao.parseQueryToFields(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
needWrapping := true
|
||||
|
||||
idField := parsed[schema.FieldNameId]
|
||||
if idField != nil && idField.field != nil &&
|
||||
idField.field.Type != schema.FieldTypeJson &&
|
||||
idField.field.Type != schema.FieldTypeNumber &&
|
||||
idField.field.Type != schema.FieldTypeBool {
|
||||
needWrapping = false
|
||||
}
|
||||
|
||||
if !needWrapping {
|
||||
return query, nil // no changes needed
|
||||
}
|
||||
|
||||
// raw parse to preserve the columns order
|
||||
rawParsed := new(identifiersParser)
|
||||
if err := rawParsed.parse(query); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
columns := make([]string, 0, len(rawParsed.columns))
|
||||
for _, col := range rawParsed.columns {
|
||||
if col.alias == schema.FieldNameId {
|
||||
columns = append(columns, fmt.Sprintf("cast([[%s]] as text) [[%s]]", col.alias, col.alias))
|
||||
} else {
|
||||
columns = append(columns, "[["+col.alias+"]]")
|
||||
}
|
||||
}
|
||||
|
||||
query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
|
||||
|
||||
return query, nil
|
||||
}
|
||||
|
||||
// resaveViewsWithChangedSchema updates all view collections with changed schemas.
|
||||
func (dao *Dao) resaveViewsWithChangedSchema(excludeIds ...string) error {
|
||||
collections, err := dao.FindCollectionsByType(models.CollectionTypeView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for _, collection := range collections {
|
||||
if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
|
||||
continue
|
||||
}
|
||||
|
||||
// clone the existing schema so that it is safe for temp modifications
|
||||
oldSchema, err := collection.Schema.Clone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// generate a new schema from the query
|
||||
newSchema, err := txDao.CreateViewSchema(collection.ViewOptions().Query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unset the schema field ids to exclude from the comparison
|
||||
for _, f := range oldSchema.Fields() {
|
||||
f.Id = ""
|
||||
}
|
||||
for _, f := range newSchema.Fields() {
|
||||
f.Id = ""
|
||||
}
|
||||
|
||||
encodedNewSchema, err := json.Marshal(newSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encodedOldSchema, err := json.Marshal(oldSchema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if bytes.EqualFold(encodedNewSchema, encodedOldSchema) {
|
||||
continue // no changes
|
||||
}
|
||||
|
||||
if err := txDao.saveViewCollection(collection, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+260
-37
@@ -6,14 +6,18 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestCollectionQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -26,6 +30,8 @@ func TestCollectionQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionsByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -61,6 +67,8 @@ func TestFindCollectionsByType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -90,6 +98,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsCollectionNameUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -114,6 +124,8 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -141,9 +153,11 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
"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 {
|
||||
@@ -162,12 +176,14 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
colEmpty := &models.Collection{}
|
||||
colUnsaved := &models.Collection{}
|
||||
|
||||
colAuth, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
colAuth, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,7 +202,17 @@ func TestDeleteCollection(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
colBase, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
colView2, err := app.Dao().FindCollectionByNameOrId("view2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -195,11 +221,15 @@ func TestDeleteCollection(t *testing.T) {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{colEmpty, true},
|
||||
{colAuth, false},
|
||||
{colUnsaved, true},
|
||||
{colReferenced, true},
|
||||
{colSystem, true},
|
||||
{colView, false},
|
||||
{colBase, true}, // depend on view1, view2 and view2
|
||||
{colView1, true}, // view2 depend on it
|
||||
{colView2, false},
|
||||
{colView1, false}, // no longer has dependent collections
|
||||
{colBase, false}, // no longer has dependent views
|
||||
{colAuth, false}, // should delete also its related external auths
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@@ -218,10 +248,25 @@ func TestDeleteCollection(t *testing.T) {
|
||||
if app.Dao().HasTable(s.model.Name) {
|
||||
t.Errorf("[%d] Expected table/view %s to be deleted", i, s.model.Name)
|
||||
}
|
||||
|
||||
// check if the external auths were deleted
|
||||
if s.model.IsAuth() {
|
||||
var total int
|
||||
err := app.Dao().ExternalAuthQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.HashExp{"collectionId": s.model.Id}).
|
||||
Row(&total)
|
||||
|
||||
if err != nil || total > 0 {
|
||||
t.Fatalf("[%d] Expected external auths to be deleted, got %v (%v)", i, total, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCollectionCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -252,7 +297,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if the records table has the schema fields
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
columns, err := app.Dao().TableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -268,6 +313,8 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveCollectionUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -291,7 +338,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
|
||||
// check if the records table has the schema fields
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
columns, err := app.Dao().TableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -305,8 +352,183 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// indirect update of a field used in view should cause view(s) update
|
||||
func TestSaveCollectionIndirectViewsUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// update MaxSelect fields
|
||||
{
|
||||
relMany := collection.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
relManyOpt.MaxSelect = types.Pointer(1)
|
||||
|
||||
fileOne := collection.Schema.GetFieldByName("file_one")
|
||||
fileOneOpt := fileOne.Options.(*schema.FileOptions)
|
||||
fileOneOpt.MaxSelect = 10
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// check view1 schema
|
||||
{
|
||||
view1, err := app.Dao().FindCollectionByNameOrId("view1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relMany := view1.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
|
||||
t.Fatalf("Expected view1.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
|
||||
}
|
||||
|
||||
fileOne := view1.Schema.GetFieldByName("file_one")
|
||||
fileOneOpt := fileOne.Options.(*schema.FileOptions)
|
||||
if fileOneOpt.MaxSelect != 10 {
|
||||
t.Fatalf("Expected view1.file_one MaxSelect to be %d, got %v", 10, fileOneOpt.MaxSelect)
|
||||
}
|
||||
}
|
||||
|
||||
// check view2 schema
|
||||
{
|
||||
view2, err := app.Dao().FindCollectionByNameOrId("view2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
relMany := view2.Schema.GetFieldByName("rel_many")
|
||||
relManyOpt := relMany.Options.(*schema.RelationOptions)
|
||||
if relManyOpt.MaxSelect == nil || *relManyOpt.MaxSelect != 1 {
|
||||
t.Fatalf("Expected view2.rel_many MaxSelect to be %d, got %v", 1, relManyOpt.MaxSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCollectionViewWrapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
viewName := "test_wrapping"
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"no wrapping - text field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - id field",
|
||||
"select text as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select text as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - relation field",
|
||||
"select rel_one as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select rel_one as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - select field",
|
||||
"select select_many as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select select_many as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - email field",
|
||||
"select email as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select email as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - datetime field",
|
||||
"select datetime as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select datetime as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"no wrapping - url field",
|
||||
"select url as id, bool from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select url as id, bool from demo1)",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field",
|
||||
"select bool as id, text as txt, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`txt`,`url` FROM (select bool as id, text as txt, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - bool field (different order)",
|
||||
"select text as txt, url, bool as id from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT `txt`,`url`,cast(`id` as text) `id` FROM (select text as txt, url, bool as id from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - json field",
|
||||
"select json as id, text, url from demo1",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id`,`text`,`url` FROM (select json as id, text, url from demo1))",
|
||||
},
|
||||
{
|
||||
"wrapping - numeric id",
|
||||
"select 1 as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select 1 as id))",
|
||||
},
|
||||
{
|
||||
"wrapping - expresion",
|
||||
"select ('test') as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (SELECT cast(`id` as text) `id` FROM (select ('test') as id))",
|
||||
},
|
||||
{
|
||||
"no wrapping - cast as text",
|
||||
"select cast('test' as text) as id",
|
||||
"CREATE VIEW `test_wrapping` AS SELECT * FROM (select cast('test' as text) as id)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: viewName,
|
||||
Type: models.CollectionTypeView,
|
||||
Options: types.JsonMap{
|
||||
"query": s.query,
|
||||
},
|
||||
}
|
||||
|
||||
err := app.Dao().SaveCollection(collection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sql string
|
||||
|
||||
rowErr := app.Dao().DB().NewQuery("SELECT sql FROM sqlite_master WHERE type='view' AND name={:name}").
|
||||
Bind(dbx.Params{"name": viewName}).
|
||||
Row(&sql)
|
||||
if rowErr != nil {
|
||||
t.Fatalf("Failed to retrieve view sql: %v", rowErr)
|
||||
}
|
||||
|
||||
if sql != s.expected {
|
||||
t.Fatalf("Expected query \n%v, \ngot \n%v", s.expected, sql)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCollections(t *testing.T) {
|
||||
totalCollections := 10
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -435,7 +657,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"name":"demo1_rename",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -536,7 +758,7 @@ func TestImportCollections(t *testing.T) {
|
||||
"demo1": 15,
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"demo4": 13,
|
||||
"demo5": 6,
|
||||
"new_import": 1,
|
||||
}
|
||||
@@ -554,37 +776,38 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
importedCollections := []*models.Collection{}
|
||||
importedCollections := []*models.Collection{}
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
|
||||
continue
|
||||
}
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), &importedCollections)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load data: %v", loadErr)
|
||||
}
|
||||
|
||||
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
|
||||
err := testApp.Dao().ImportCollections(importedCollections, s.deleteMissing, s.beforeRecordsSync)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
|
||||
}
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != scenario.expectCollectionsCount {
|
||||
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
|
||||
}
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
if scenario.afterTestFunc != nil {
|
||||
scenario.afterTestFunc(testApp, collections)
|
||||
}
|
||||
if s.afterTestFunc != nil {
|
||||
s.afterTestFunc(testApp, collections)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+18
-21
@@ -32,27 +32,6 @@ func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*mode
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByProvider returns the first available
|
||||
// ExternalAuth model for the specified provider and providerId.
|
||||
func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(dbx.HashExp{
|
||||
"provider": provider,
|
||||
"providerId": providerId,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByRecordAndProvider returns the first available
|
||||
// ExternalAuth model for the specified record data and provider.
|
||||
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
|
||||
@@ -74,6 +53,24 @@ func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, p
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindFirstExternalAuthByExpr returns the first available
|
||||
// ExternalAuth model that satisfies the non-nil expression.
|
||||
func (dao *Dao) FindFirstExternalAuthByExpr(expr dbx.Expression) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"providerId": ""})). // exclude empty providerIds
|
||||
AndWhere(expr).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// SaveExternalAuth upserts the provided ExternalAuth model.
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
|
||||
+26
-11
@@ -3,11 +3,14 @@ package daos_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestExternalAuthQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -20,6 +23,8 @@ func TestExternalAuthQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -56,25 +61,25 @@ func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
func TestFindFirstExternalAuthByExpr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
provider string
|
||||
providerId string
|
||||
expr dbx.Expression
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
{dbx.HashExp{"provider": "github", "providerId": ""}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id1"}, ""},
|
||||
{dbx.HashExp{"provider": "github", "providerId": "id2"}, ""},
|
||||
{dbx.HashExp{"provider": "google", "providerId": "test123"}, "clmflokuq1xl341"},
|
||||
{dbx.HashExp{"provider": "gitlab", "providerId": "test123"}, "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByProvider(s.provider, s.providerId)
|
||||
auth, err := app.Dao().FindFirstExternalAuthByExpr(s.expr)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
@@ -90,6 +95,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -126,6 +133,8 @@ func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -147,7 +156,11 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if it was really saved
|
||||
foundAuth, err := app.Dao().FindExternalAuthByProvider("test", "test_id")
|
||||
foundAuth, err := app.Dao().FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": "v851q4r790rhknl",
|
||||
"provider": "test",
|
||||
"providerId": "test_id",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -158,6 +171,8 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteExternalAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// LogQuery returns a new Log select query.
|
||||
func (dao *Dao) LogQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Log{})
|
||||
}
|
||||
|
||||
// FindLogById finds a single Log entry by its id.
|
||||
func (dao *Dao) FindLogById(id string) (*models.Log, error) {
|
||||
model := &models.Log{}
|
||||
|
||||
err := dao.LogQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type LogsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// LogsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) LogsStats(expr dbx.Expression) ([]*LogsStatsItem, error) {
|
||||
result := []*LogsStatsItem{}
|
||||
|
||||
query := dao.LogQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldLogs delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldLogs(createdBefore time.Time) error {
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.NonconcurrentDB().Delete((&models.Log{}).TableName(), expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveLog upserts the provided Log model.
|
||||
func (dao *Dao) SaveLog(log *models.Log) error {
|
||||
return dao.Save(log)
|
||||
}
|
||||
@@ -11,23 +11,27 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRequestQuery(t *testing.T) {
|
||||
func TestLogQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_requests}}.* FROM `_requests`"
|
||||
expected := "SELECT {{_logs}}.* FROM `_logs`"
|
||||
|
||||
sql := app.Dao().RequestQuery().Build().SQL()
|
||||
sql := app.Dao().LogQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRequestById(t *testing.T) {
|
||||
func TestFindLogById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
@@ -40,7 +44,7 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
admin, err := app.LogsDao().FindRequestById(scenario.id)
|
||||
admin, err := app.LogsDao().FindLogById(scenario.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@@ -53,17 +57,19 @@ func TestFindRequestById(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsStats(t *testing.T) {
|
||||
func TestLogsStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
|
||||
|
||||
now := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
|
||||
result, err := app.LogsDao().RequestsStats(exp)
|
||||
result, err := app.LogsDao().LogsStats(exp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,20 +80,22 @@ func TestRequestsStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldRequests(t *testing.T) {
|
||||
func TestDeleteOldLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
scenarios := []struct {
|
||||
date string
|
||||
expectedTotal int
|
||||
}{
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no logs to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 log should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more logs should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more logs should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@@ -96,53 +104,55 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
t.Errorf("(%d) Date error %v", i, dateErr)
|
||||
}
|
||||
|
||||
deleteErr := app.LogsDao().DeleteOldRequests(date)
|
||||
deleteErr := app.LogsDao().DeleteOldLogs(date)
|
||||
if deleteErr != nil {
|
||||
t.Errorf("(%d) Delete error %v", i, deleteErr)
|
||||
}
|
||||
|
||||
// check total remaining requests
|
||||
// check total remaining logs
|
||||
var total int
|
||||
countErr := app.LogsDao().RequestQuery().Select("count(*)").Row(&total)
|
||||
countErr := app.LogsDao().LogQuery().Select("count(*)").Row(&total)
|
||||
if countErr != nil {
|
||||
t.Errorf("(%d) Count error %v", i, countErr)
|
||||
}
|
||||
|
||||
if total != scenario.expectedTotal {
|
||||
t.Errorf("(%d) Expected %d remaining requests, got %d", i, scenario.expectedTotal, total)
|
||||
t.Errorf("(%d) Expected %d remaining logs, got %d", i, scenario.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRequest(t *testing.T) {
|
||||
func TestSaveLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
tests.MockLogsData(app)
|
||||
|
||||
// create new request
|
||||
newRequest := &models.Request{}
|
||||
newRequest.Method = "get"
|
||||
newRequest.Meta = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveRequest(newRequest)
|
||||
// create new log
|
||||
newLog := &models.Log{}
|
||||
newLog.Level = -4
|
||||
newLog.Data = types.JsonMap{}
|
||||
createErr := app.LogsDao().SaveLog(newLog)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
|
||||
// check if it was really created
|
||||
existingRequest, fetchErr := app.LogsDao().FindRequestById(newRequest.Id)
|
||||
existingLog, fetchErr := app.LogsDao().FindLogById(newLog.Id)
|
||||
if fetchErr != nil {
|
||||
t.Fatal(fetchErr)
|
||||
}
|
||||
|
||||
existingRequest.Method = "post"
|
||||
updateErr := app.LogsDao().SaveRequest(existingRequest)
|
||||
existingLog.Level = 4
|
||||
updateErr := app.LogsDao().SaveLog(existingLog)
|
||||
if updateErr != nil {
|
||||
t.Fatal(updateErr)
|
||||
}
|
||||
// refresh instance to check if it was really updated
|
||||
existingRequest, _ = app.LogsDao().FindRequestById(existingRequest.Id)
|
||||
if existingRequest.Method != "post" {
|
||||
t.Fatalf("Expected request method to be %s, got %s", "post", existingRequest.Method)
|
||||
existingLog, _ = app.LogsDao().FindLogById(existingLog.Id)
|
||||
if existingLog.Level != 4 {
|
||||
t.Fatalf("Expected log level to be %d, got %d", 4, existingLog.Level)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestParamQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -23,6 +25,8 @@ func TestParamQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindParamByKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -50,6 +54,8 @@ func TestFindParamByKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -92,6 +98,8 @@ func TestSaveParam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveParamEncrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -126,6 +134,8 @@ func TestSaveParamEncrypted(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+302
-90
@@ -1,93 +1,133 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
tableName := collection.Name
|
||||
// RecordQuery returns a new Record select query from a collection model, id or name.
|
||||
//
|
||||
// In case a collection id or name is provided and that collection doesn't
|
||||
// actually exists, the generated query will be created with a cancelled context
|
||||
// and will fail once an executor (Row(), One(), All(), etc.) is called.
|
||||
func (dao *Dao) RecordQuery(collectionModelOrIdentifier any) *dbx.SelectQuery {
|
||||
var tableName string
|
||||
var collection *models.Collection
|
||||
var collectionErr error
|
||||
switch c := collectionModelOrIdentifier.(type) {
|
||||
case *models.Collection:
|
||||
collection = c
|
||||
tableName = collection.Name
|
||||
case models.Collection:
|
||||
collection = &c
|
||||
tableName = collection.Name
|
||||
case string:
|
||||
collection, collectionErr = dao.FindCollectionByNameOrId(c)
|
||||
if collection != nil {
|
||||
tableName = collection.Name
|
||||
} else {
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__missing_" + c
|
||||
}
|
||||
default:
|
||||
// update with some fake table name for easier debugging
|
||||
tableName = "@@__invalidCollectionModelOrIdentifier"
|
||||
collectionErr = errors.New("unsupported collection identifier, must be collection model, id or name")
|
||||
}
|
||||
|
||||
selectCols := fmt.Sprintf("%s.*", dao.DB().QuoteSimpleColumnName(tableName))
|
||||
|
||||
return dao.DB().
|
||||
Select(selectCols).
|
||||
From(tableName).
|
||||
WithBuildHook(func(query *dbx.Query) {
|
||||
query.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
query := dao.DB().Select(selectCols).From(tableName)
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
// in case of an error attach a new context and cancel it immediately with the error
|
||||
if collectionErr != nil {
|
||||
// @todo consider changing to WithCancelCause when upgrading
|
||||
// the min Go requirement to 1.20, so that we can pass the error
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
query.WithContext(ctx)
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
default:
|
||||
return query.WithBuildHook(func(q *dbx.Query) {
|
||||
q.WithExecHook(execLockRetry(dao.ModelQueryTimeout, dao.MaxLockRetries)).
|
||||
WithOneHook(func(q *dbx.Query, a any, op func(b any) error) error {
|
||||
switch v := a.(type) {
|
||||
case *models.Record:
|
||||
if v == nil {
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
row := dbx.NullStringMap{}
|
||||
if err := op(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
record := models.NewRecordFromNullStringMap(collection, row)
|
||||
|
||||
*v = records
|
||||
*v = *record
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
default:
|
||||
return op(a)
|
||||
}
|
||||
}).
|
||||
WithAllHook(func(q *dbx.Query, sliceA any, op func(sliceB any) error) error {
|
||||
switch v := sliceA.(type) {
|
||||
case *[]*models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
*v = records
|
||||
|
||||
return nil
|
||||
case *[]models.Record:
|
||||
if v == nil {
|
||||
return op(sliceA)
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := op(&rows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
nonPointers := make([]models.Record, len(records))
|
||||
for i, r := range records {
|
||||
nonPointers[i] = *r
|
||||
}
|
||||
|
||||
*v = nonPointers
|
||||
|
||||
return nil
|
||||
default:
|
||||
return op(sliceA)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
@@ -170,12 +210,7 @@ func (dao *Dao) FindRecordsByIds(
|
||||
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
query := dao.RecordQuery(collectionNameOrId)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
@@ -200,14 +235,9 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
key string,
|
||||
value any,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
err := dao.RecordQuery(collectionNameOrId).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
@@ -218,6 +248,113 @@ func (dao *Dao) FindFirstRecordByData(
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindRecordsByFilter returns limit number of records matching the
|
||||
// provided string filter.
|
||||
//
|
||||
// NB! Use the last "params" argument to bind untrusted user variables!
|
||||
//
|
||||
// The sort argument is optional and can be empty string OR the same format
|
||||
// used in the web APIs, eg. "-created,title".
|
||||
//
|
||||
// If the limit argument is <= 0, no limit is applied to the query and
|
||||
// all matching records are returned.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindRecordsByFilter(
|
||||
// "posts",
|
||||
// "title ~ {:title} && visible = {:visible}",
|
||||
// "-created",
|
||||
// 10,
|
||||
// 0,
|
||||
// dbx.Params{"title": "lorem ipsum", "visible": true}
|
||||
// )
|
||||
func (dao *Dao) FindRecordsByFilter(
|
||||
collectionNameOrId string,
|
||||
filter string,
|
||||
sort string,
|
||||
limit int,
|
||||
offset int,
|
||||
params ...dbx.Params,
|
||||
) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := dao.RecordQuery(collection)
|
||||
|
||||
// build a fields resolver and attach the generated conditions to the query
|
||||
// ---
|
||||
resolver := resolvers.NewRecordFieldResolver(
|
||||
dao,
|
||||
collection, // the base collection
|
||||
nil, // no request data
|
||||
true, // allow searching hidden/protected fields like "email"
|
||||
)
|
||||
|
||||
expr, err := search.FilterData(filter).BuildExpr(resolver, params...)
|
||||
if err != nil || expr == nil {
|
||||
return nil, errors.New("invalid or empty filter expression")
|
||||
}
|
||||
q.AndWhere(expr)
|
||||
|
||||
if sort != "" {
|
||||
for _, sortField := range search.ParseSortFromString(sort) {
|
||||
expr, err := sortField.BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expr != "" {
|
||||
q.AndOrderBy(expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolver.UpdateQuery(q) // attaches any adhoc joins and aliases
|
||||
// ---
|
||||
|
||||
if offset > 0 {
|
||||
q.Offset(int64(offset))
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
q.Limit(int64(limit))
|
||||
}
|
||||
|
||||
records := []*models.Record{}
|
||||
|
||||
if err := q.All(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// FindFirstRecordByFilter returns the first available record matching the provided filter.
|
||||
//
|
||||
// NB! Use the last params argument to bind untrusted user variables!
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
|
||||
func (dao *Dao) FindFirstRecordByFilter(
|
||||
collectionNameOrId string,
|
||||
filter string,
|
||||
params ...dbx.Params,
|
||||
) (*models.Record, error) {
|
||||
result, err := dao.FindRecordsByFilter(collectionNameOrId, filter, "", 1, 0, params...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return result[0], nil
|
||||
}
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
@@ -247,9 +384,9 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
normalizedVal = append(types.JsonArray[string]{}, val...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
normalizedVal = append(types.JsonArray[any]{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
@@ -271,9 +408,9 @@ func (dao *Dao) IsRecordValueUnique(
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
|
||||
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
@@ -386,7 +523,66 @@ func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
return username
|
||||
}
|
||||
|
||||
// SaveRecord upserts the provided Record model.
|
||||
// CanAccessRecord checks if a record is allowed to be accessed by the
|
||||
// specified requestInfo and accessRule.
|
||||
//
|
||||
// Rule and db checks are ignored in case requestInfo.Admin is set.
|
||||
//
|
||||
// The returned error indicate that something unexpected happened during
|
||||
// the check (eg. invalid rule or db error).
|
||||
//
|
||||
// The method always return false on invalid access rule or db error.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// requestInfo := apis.RequestInfo(c /* echo.Context */)
|
||||
// record, _ := dao.FindRecordById("example", "RECORD_ID")
|
||||
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
|
||||
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
|
||||
//
|
||||
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
|
||||
func (dao *Dao) CanAccessRecord(record *models.Record, requestInfo *models.RequestInfo, accessRule *string) (bool, error) {
|
||||
if requestInfo.Admin != nil {
|
||||
// admins can access everything
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if accessRule == nil {
|
||||
// only admins can access this record
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if *accessRule == "" {
|
||||
// empty public rule, aka. everyone can access
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
query := dao.RecordQuery(record.Collection()).
|
||||
Select("(1)").
|
||||
AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})
|
||||
|
||||
// parse and apply the access rule filter
|
||||
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
||||
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resolver.UpdateQuery(query)
|
||||
query.AndWhere(expr)
|
||||
|
||||
if err := query.Limit(1).Row(&exists); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// SaveRecord persists the provided Record model in the database.
|
||||
//
|
||||
// If record.IsNew() is true, the method will perform a create, otherwise an update.
|
||||
// To explicitly mark a record for update you can use record.MarkAsNotNew().
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
@@ -461,25 +657,41 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
//
|
||||
// NB! This method is expected to be called inside a transaction.
|
||||
func (dao *Dao) cascadeRecordDelete(mainRecord *models.Record, refs map[*models.Collection][]*schema.SchemaField) error {
|
||||
uniqueJsonEachAlias := "__je__" + security.PseudorandomString(4)
|
||||
// @todo consider changing refs to a slice
|
||||
//
|
||||
// Sort the refs keys to ensure that the cascade events firing order is always the same.
|
||||
// This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
|
||||
sortedRefKeys := make([]*models.Collection, 0, len(refs))
|
||||
for k := range refs {
|
||||
sortedRefKeys = append(sortedRefKeys, k)
|
||||
}
|
||||
sort.Slice(sortedRefKeys, func(i, j int) bool {
|
||||
return sortedRefKeys[i].Name < sortedRefKeys[j].Name
|
||||
})
|
||||
|
||||
for refCollection, fields := range refs {
|
||||
if refCollection.IsView() {
|
||||
continue // skip view collections
|
||||
for _, refCollection := range sortedRefKeys {
|
||||
fields, ok := refs[refCollection]
|
||||
|
||||
if refCollection.IsView() || !ok {
|
||||
continue // skip missing or view collections
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
recordTableName := inflector.Columnify(refCollection.Name)
|
||||
prefixedFieldName := recordTableName + "." + inflector.Columnify(field.Name)
|
||||
|
||||
// @todo optimize single relation lookup
|
||||
query := dao.RecordQuery(refCollection).
|
||||
Distinct(true).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{%s}}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName, uniqueJsonEachAlias,
|
||||
), dbx.HashExp{uniqueJsonEachAlias + ".value": mainRecord.Id})
|
||||
query := dao.RecordQuery(refCollection)
|
||||
|
||||
if opt, ok := field.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
|
||||
query.AndWhere(dbx.HashExp{prefixedFieldName: mainRecord.Id})
|
||||
} else {
|
||||
query.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
|
||||
`SELECT 1 FROM json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) {{__je__}} WHERE [[__je__.value]]={:jevalue}`,
|
||||
prefixedFieldName, prefixedFieldName, prefixedFieldName,
|
||||
), dbx.Params{
|
||||
"jevalue": mainRecord.Id,
|
||||
})))
|
||||
}
|
||||
|
||||
if refCollection.Id == mainRecord.Collection().Id {
|
||||
query.AndWhere(dbx.Not(dbx.HashExp{recordTableName + ".id": mainRecord.Id}))
|
||||
|
||||
+87
-45
@@ -3,19 +3,22 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// MaxExpandDepth specifies the max allowed nested expand depth path.
|
||||
//
|
||||
// @todo Consider eventually reusing resolvers.maxNestedRels
|
||||
const MaxExpandDepth = 6
|
||||
|
||||
// ExpandFetchFunc defines the function that is used to fetch the expanded relation records.
|
||||
@@ -23,21 +26,27 @@ type ExpandFetchFunc func(relCollection *models.Collection, relIds []string) ([]
|
||||
|
||||
// ExpandRecord expands the relations of a single Record model.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, fetchFunc)
|
||||
func (dao *Dao) ExpandRecord(record *models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
return dao.ExpandRecords([]*models.Record{record}, expands, optFetchFunc)
|
||||
}
|
||||
|
||||
// ExpandRecords expands the relations of the provided Record models list.
|
||||
//
|
||||
// If optFetchFunc is not set, then a default function will be used
|
||||
// that returns all relation records.
|
||||
//
|
||||
// Returns a map with the failed expand parameters and their errors.
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchFunc ExpandFetchFunc) map[string]error {
|
||||
func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, optFetchFunc ExpandFetchFunc) map[string]error {
|
||||
normalized := normalizeExpands(expands)
|
||||
|
||||
failed := map[string]error{}
|
||||
|
||||
for _, expand := range normalized {
|
||||
if err := dao.expandRecords(records, expand, fetchFunc, 1); err != nil {
|
||||
if err := dao.expandRecords(records, expand, optFetchFunc, 1); err != nil {
|
||||
failed[expand] = err
|
||||
}
|
||||
}
|
||||
@@ -45,16 +54,21 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
return failed
|
||||
}
|
||||
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
// Deprecated
|
||||
var indirectExpandRegexOld = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)_via_(\w+)$`)
|
||||
|
||||
// notes:
|
||||
// - fetchFunc must be non-nil func
|
||||
// - if fetchFunc is nil, dao.FindRecordsByIds will be used
|
||||
// - all records are expected to be from the same collection
|
||||
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
|
||||
// - indirect expands are supported only with single relation fields
|
||||
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
|
||||
if fetchFunc == nil {
|
||||
return errors.New("Relation records fetchFunc is not set.")
|
||||
// load a default fetchFunc
|
||||
fetchFunc = func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
|
||||
return dao.FindRecordsByIds(relCollection.Id, relIds)
|
||||
}
|
||||
}
|
||||
|
||||
if expandPath == "" || recursionLevel > MaxExpandDepth || len(records) == 0 {
|
||||
@@ -68,70 +82,87 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
var relCollection *models.Collection
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
|
||||
var matches []string
|
||||
|
||||
// @todo remove the old syntax support
|
||||
if strings.Contains(parts[0], "(") {
|
||||
matches = indirectExpandRegexOld.FindStringSubmatch(parts[0])
|
||||
if len(matches) == 3 {
|
||||
log.Printf(
|
||||
"%s expand format is deprecated and will be removed in the future. Consider replacing it with %s_via_%s.\n",
|
||||
matches[0],
|
||||
matches[1],
|
||||
matches[2],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
matches = indirectExpandRegex.FindStringSubmatch(parts[0])
|
||||
}
|
||||
|
||||
if len(matches) == 3 {
|
||||
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
|
||||
if indirectRel == nil {
|
||||
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
|
||||
return fmt.Errorf("couldn't find back-related collection %q", matches[1])
|
||||
}
|
||||
|
||||
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
|
||||
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
|
||||
return fmt.Errorf("couldn't find back-relation field %q in collection %q", matches[2], indirectRel.Name)
|
||||
}
|
||||
|
||||
indirectRelField.InitOptions()
|
||||
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
|
||||
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
|
||||
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
|
||||
}
|
||||
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
|
||||
// for now don't allow multi-relation indirect fields expand
|
||||
// due to eventual poor query performance with large data sets.
|
||||
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
|
||||
return fmt.Errorf("invalid back-relation field path %q", parts[0])
|
||||
}
|
||||
|
||||
recordIds := make([]any, len(records))
|
||||
for i, record := range records {
|
||||
recordIds[i] = record.Id
|
||||
}
|
||||
// add the related id(s) as a dynamic relation field value to
|
||||
// allow further expand checks at later stage in a more unified manner
|
||||
prepErr := func() error {
|
||||
q := dao.DB().Select("id").
|
||||
From(indirectRel.Name).
|
||||
Limit(1000) // the limit is arbitrary chosen and may change in the future
|
||||
|
||||
// @todo after the index optimizations consider allowing
|
||||
// indirect expand for multi-relation fields
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
indirectRel.Id,
|
||||
dbx.In(inflector.Columnify(matches[2]), recordIds...),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
|
||||
for _, indirectRecord := range indirectRecords {
|
||||
recId := indirectRecord.GetString(matches[2])
|
||||
if recId != "" {
|
||||
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
|
||||
if indirectRelFieldOptions.IsMultiple() {
|
||||
q.AndWhere(dbx.Exists(dbx.NewExp(fmt.Sprintf(
|
||||
"SELECT 1 FROM %s je WHERE je.value = {:id}",
|
||||
dbutils.JsonEach(indirectRelField.Name),
|
||||
))))
|
||||
} else {
|
||||
q.AndWhere(dbx.NewExp("[[" + indirectRelField.Name + "]] = {:id}"))
|
||||
}
|
||||
}
|
||||
|
||||
// add the indirect relation ids as a new relation field value
|
||||
for _, record := range records {
|
||||
relIds, ok := mappedIndirectRecordIds[record.Id]
|
||||
if ok && len(relIds) > 0 {
|
||||
record.Set(parts[0], relIds)
|
||||
pq := q.Build().Prepare()
|
||||
|
||||
for _, record := range records {
|
||||
var relIds []string
|
||||
|
||||
err := pq.Bind(dbx.Params{"id": record.Id}).Column(&relIds)
|
||||
if err != nil {
|
||||
return errors.Join(err, pq.Close())
|
||||
}
|
||||
|
||||
if len(relIds) > 0 {
|
||||
record.Set(parts[0], relIds)
|
||||
}
|
||||
}
|
||||
|
||||
return pq.Close()
|
||||
}()
|
||||
if prepErr != nil {
|
||||
return prepErr
|
||||
}
|
||||
|
||||
relFieldOptions = &schema.RelationOptions{
|
||||
MaxSelect: nil,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if indirectRelField.Unique {
|
||||
if dbutils.HasSingleColumnUniqueIndex(indirectRelField.Name, indirectRel.Indexes) {
|
||||
relFieldOptions.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
// indirect relation
|
||||
// indirect/back relation
|
||||
relField = &schema.SchemaField{
|
||||
Id: "indirect_" + security.PseudorandomString(5),
|
||||
Id: "_" + parts[0] + security.PseudorandomString(3),
|
||||
Type: schema.FieldTypeRelation,
|
||||
Name: parts[0],
|
||||
Options: relFieldOptions,
|
||||
@@ -269,3 +300,14 @@ func normalizeExpands(paths []string) []string {
|
||||
|
||||
return list.ToUniqueStringSlice(result)
|
||||
}
|
||||
|
||||
func isRelFieldUnique(collection *models.Collection, fieldName string) bool {
|
||||
for _, idx := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(idx)
|
||||
if parsed.Unique && len(parsed.Columns) == 1 && strings.EqualFold(parsed.Columns[0].Name, fieldName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
+139
-23
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func TestExpandRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -48,15 +50,6 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
@@ -101,6 +94,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
1,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
@@ -132,6 +138,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"with nil fetchfunc",
|
||||
"users",
|
||||
[]string{
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"rel"},
|
||||
nil,
|
||||
2,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
@@ -144,7 +163,7 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"simple back single relation field expand (deprecated syntax)",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
@@ -155,11 +174,22 @@ func TestExpandRecords(t *testing.T) {
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"simple back expand via single relation field",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{"demo4_via_rel_one_no_cascade_required"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested back expand via single relation field",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
"demo4_via_rel_one_no_cascade_required.self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
@@ -167,6 +197,19 @@ func TestExpandRecords(t *testing.T) {
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested back expand via multiple relation field",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{
|
||||
"demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
7,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"expand multiple relations sharing a common path",
|
||||
"demo4",
|
||||
@@ -205,6 +248,8 @@ func TestExpandRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExpandRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -228,15 +273,6 @@ func TestExpandRecord(t *testing.T) {
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
{
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
@@ -320,7 +356,7 @@ func TestExpandRecord(t *testing.T) {
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"simple indirect expand via single relation field (deprecated syntax)",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
@@ -331,7 +367,18 @@ func TestExpandRecord(t *testing.T) {
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"simple indirect expand via single relation field",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{"demo4_via_rel_one_no_cascade_required"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand via single relation field",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{
|
||||
@@ -343,6 +390,19 @@ func TestExpandRecord(t *testing.T) {
|
||||
5,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand via single relation field",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{
|
||||
"demo4_via_rel_many_no_cascade_required.self_rel_many.rel_many_no_cascade_required.demo4_via_rel_many_no_cascade_required",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
7,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -362,3 +422,59 @@ func TestExpandRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndirectExpandSingeVsArrayResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
record, err := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// non-unique indirect expand
|
||||
{
|
||||
errs := app.Dao().ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
|
||||
result, ok := record.Expand()["demo4_via_rel_one_cascade"].([]*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected the expanded result to be a slice, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// unique indirect expand
|
||||
{
|
||||
// mock a unique constraint for the rel_one_cascade field
|
||||
// ---
|
||||
demo4, err := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
demo4.Indexes = append(demo4.Indexes, "create unique index idx_unique_expand on demo4 (rel_one_cascade)")
|
||||
|
||||
if err := app.Dao().SaveCollection(demo4); err != nil {
|
||||
t.Fatalf("Failed to mock unique constraint: %v", err)
|
||||
}
|
||||
// ---
|
||||
|
||||
errs := app.Dao().ExpandRecord(record, []string{"demo4_via_rel_one_cascade"}, func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
})
|
||||
if len(errs) > 0 {
|
||||
t.Fatal(errs)
|
||||
}
|
||||
|
||||
result, ok := record.Expand()["demo4_via_rel_one_cascade"].(*models.Record)
|
||||
if !ok {
|
||||
t.Fatalf("Expected the expanded result to be a single model, got %v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+254
-92
@@ -2,11 +2,14 @@ package daos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@@ -15,70 +18,67 @@ import (
|
||||
//
|
||||
// If `oldCollection` is null, then only `newCollection` is used to create the record table.
|
||||
func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldCollection *models.Collection) error {
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// create
|
||||
// -----------------------------------------------------------
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
tableName := newCollection.Name
|
||||
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
// add schema field definitions
|
||||
for _, field := range newCollection.Schema.Fields() {
|
||||
cols[field.Name] = field.ColDefinition()
|
||||
}
|
||||
|
||||
// create table
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
// create table
|
||||
if _, err := txDao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.createCollectionIndexes(newCollection)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// update
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// update
|
||||
// -----------------------------------------------------------
|
||||
oldTableName := oldCollection.Name
|
||||
newTableName := newCollection.Name
|
||||
oldSchema := oldCollection.Schema
|
||||
@@ -86,9 +86,14 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
deletedFieldNames := []string{}
|
||||
renamedFieldNames := map[string]string{}
|
||||
|
||||
// drop old indexes (if any)
|
||||
if err := txDao.dropCollectionIndex(oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
_, err := txDao.DB().RenameTable("{{"+oldTableName+"}}", "{{"+newTableName+"}}").Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,7 +107,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
|
||||
_, err := txDao.DB().DropColumn(newTableName, oldField.Name).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to drop column %s - %w", oldField.Name, err)
|
||||
}
|
||||
|
||||
deletedFieldNames = append(deletedFieldNames, oldField.Name)
|
||||
@@ -125,7 +130,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
// add
|
||||
_, err := txDao.DB().AddColumn(newTableName, tempName, field.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to add column %s - %w", field.Name, err)
|
||||
}
|
||||
} else if oldField.Name != field.Name {
|
||||
tempName := field.Name + security.PseudorandomString(5)
|
||||
@@ -134,7 +139,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
// rename
|
||||
_, err := txDao.DB().RenameColumn(newTableName, oldField.Name, tempName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to rename column %s - %w", oldField.Name, err)
|
||||
}
|
||||
|
||||
renamedFieldNames[oldField.Name] = field.Name
|
||||
@@ -149,50 +154,207 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.syncCollectionReferences(newCollection, renamedFieldNames, deletedFieldNames)
|
||||
if err := txDao.normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return txDao.createCollectionIndexes(newCollection)
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) syncCollectionReferences(collection *models.Collection, renamedFieldNames map[string]string, deletedFieldNames []string) error {
|
||||
if len(renamedFieldNames) == 0 && len(deletedFieldNames) == 0 {
|
||||
return nil // nothing to sync
|
||||
func (dao *Dao) normalizeSingleVsMultipleFieldChanges(newCollection, oldCollection *models.Collection) error {
|
||||
if newCollection.IsView() || oldCollection == nil {
|
||||
return nil // view or not an update
|
||||
}
|
||||
|
||||
refs, err := dao.FindCollectionReferences(collection)
|
||||
if err != nil {
|
||||
return err
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// temporary disable the schema error checks to prevent view and trigger errors
|
||||
// when "altering" (aka. deleting and recreating) the non-normalized columns
|
||||
if _, err := txDao.DB().NewQuery("PRAGMA writable_schema = ON").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
// executed with defer to make sure that the pragma is always reverted
|
||||
// in case of an error and when nested transactions are used
|
||||
defer txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
for _, newField := range newCollection.Schema.Fields() {
|
||||
// allow to continue even if there is no old field for the cases
|
||||
// when a new field is added and there are already inserted data
|
||||
var isOldMultiple bool
|
||||
if oldField := oldCollection.Schema.GetFieldById(newField.Id); oldField != nil {
|
||||
if opt, ok := oldField.Options.(schema.MultiValuer); ok {
|
||||
isOldMultiple = opt.IsMultiple()
|
||||
}
|
||||
}
|
||||
|
||||
var isNewMultiple bool
|
||||
if opt, ok := newField.Options.(schema.MultiValuer); ok {
|
||||
isNewMultiple = opt.IsMultiple()
|
||||
}
|
||||
|
||||
if isOldMultiple == isNewMultiple {
|
||||
continue // no change
|
||||
}
|
||||
|
||||
// update the column definition by:
|
||||
// 1. inserting a new column with the new definition
|
||||
// 2. copy normalized values from the original column to the new one
|
||||
// 3. drop the original column
|
||||
// 4. rename the new column to the original column
|
||||
// -------------------------------------------------------
|
||||
|
||||
originalName := newField.Name
|
||||
tempName := "_" + newField.Name + security.PseudorandomString(5)
|
||||
|
||||
_, err := txDao.DB().AddColumn(newCollection.Name, tempName, newField.ColDefinition()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var copyQuery *dbx.Query
|
||||
|
||||
if !isOldMultiple && isNewMultiple {
|
||||
// single -> multiple (convert to array)
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '') = ''
|
||||
THEN '[]'
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN [[%s]]
|
||||
ELSE json_array([[%s]])
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
} else {
|
||||
// multiple -> single (keep only the last element)
|
||||
//
|
||||
// note: for file fields the actual file objects are not
|
||||
// deleted allowing additional custom handling via migration
|
||||
copyQuery = txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{%s}} set [[%s]] = (
|
||||
CASE
|
||||
WHEN COALESCE([[%s]], '[]') = '[]'
|
||||
THEN ''
|
||||
ELSE (
|
||||
CASE
|
||||
WHEN json_valid([[%s]]) AND json_type([[%s]]) == 'array'
|
||||
THEN COALESCE(json_extract([[%s]], '$[#-1]'), '')
|
||||
ELSE [[%s]]
|
||||
END
|
||||
)
|
||||
END
|
||||
)`,
|
||||
newCollection.Name,
|
||||
tempName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
originalName,
|
||||
))
|
||||
}
|
||||
|
||||
// copy the normalized values
|
||||
if _, err := copyQuery.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop the original column
|
||||
if _, err := txDao.DB().DropColumn(newCollection.Name, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// rename the new column back to the original
|
||||
if _, err := txDao.DB().RenameColumn(newCollection.Name, tempName, originalName).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// revert the pragma and reload the schema
|
||||
_, revertErr := txDao.DB().NewQuery("PRAGMA writable_schema = RESET").Execute()
|
||||
|
||||
return revertErr
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) dropCollectionIndex(collection *models.Collection) error {
|
||||
if collection.IsView() {
|
||||
return nil // views don't have indexes
|
||||
}
|
||||
|
||||
for refCollection, refFields := range refs {
|
||||
for _, refField := range refFields {
|
||||
options, _ := refField.Options.(*schema.RelationOptions)
|
||||
if options == nil {
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
for _, raw := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(raw)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
continue
|
||||
}
|
||||
|
||||
// remove deleted (if any)
|
||||
newDisplayFields := list.SubtractSlice(options.DisplayFields, deletedFieldNames)
|
||||
|
||||
for old, new := range renamedFieldNames {
|
||||
for i, name := range newDisplayFields {
|
||||
if name == old {
|
||||
newDisplayFields[i] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// has changes
|
||||
if len(list.SubtractSlice(options.DisplayFields, newDisplayFields)) > 0 {
|
||||
options.DisplayFields = newDisplayFields
|
||||
|
||||
// direct collection save to prevent self-referencing
|
||||
// recursion and unnecessary records table sync checks
|
||||
if err := dao.Save(refCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().NewQuery(fmt.Sprintf("DROP INDEX IF EXISTS [[%s]]", parsed.IndexName)).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (dao *Dao) createCollectionIndexes(collection *models.Collection) error {
|
||||
if collection.IsView() {
|
||||
return nil // views don't have indexes
|
||||
}
|
||||
|
||||
return nil
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// drop new indexes in case a duplicated index name is used
|
||||
if err := txDao.dropCollectionIndex(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// upsert new indexes
|
||||
//
|
||||
// note: we are returning validation errors because the indexes cannot be
|
||||
// validated in a form, aka. before persisting the related collection
|
||||
// record table changes
|
||||
errs := validation.Errors{}
|
||||
for i, idx := range collection.Indexes {
|
||||
parsed := dbutils.ParseIndex(idx)
|
||||
|
||||
// ensure that the index is always for the current collection
|
||||
parsed.TableName = collection.Name
|
||||
|
||||
if !parsed.IsValid() {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
"Invalid CREATE INDEX expression.",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := txDao.DB().NewQuery(parsed.Build()).Execute(); err != nil {
|
||||
errs[strconv.Itoa(i)] = validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
fmt.Sprintf("Failed to create index %s - %v.", parsed.IndexName, err.Error()),
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return validation.Errors{"indexes": errs}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+226
-22
@@ -1,15 +1,21 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestSyncRecordTableSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -36,15 +42,17 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
Type: schema.FieldTypeEmail,
|
||||
},
|
||||
)
|
||||
updatedCollection.Indexes = types.JsonArray[string]{"create index idx_title_renamed on anything (title_renamed)"}
|
||||
|
||||
scenarios := []struct {
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
name string
|
||||
newCollection *models.Collection
|
||||
oldCollection *models.Collection
|
||||
expectedColumns []string
|
||||
expectedIndexesCount int
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
"new base collection",
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
Schema: schema.NewSchema(
|
||||
@@ -55,11 +63,11 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
0,
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
"new auth collection",
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
@@ -69,51 +77,247 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
Indexes: types.JsonArray[string]{"create index idx_auth_test on anything (email, username)"},
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
4,
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
"no changes",
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
3,
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
"renamed table, deleted column, renamed columnd and new column",
|
||||
updatedCollection,
|
||||
oldCollection,
|
||||
"demo_renamed",
|
||||
[]string{"id", "created", "updated", "title_renamed", "new_field"},
|
||||
1,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(scenario.newCollection, scenario.oldCollection)
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SyncRecordTableSchema(s.newCollection, s.oldCollection)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) %v", i, err)
|
||||
t.Errorf("[%s] %v", s.name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !app.Dao().HasTable(scenario.newCollection.Name) {
|
||||
t.Errorf("(%d) Expected table %s to exist", i, scenario.newCollection.Name)
|
||||
if !app.Dao().HasTable(s.newCollection.Name) {
|
||||
t.Errorf("[%s] Expected table %s to exist", s.name, s.newCollection.Name)
|
||||
}
|
||||
|
||||
cols, _ := app.Dao().GetTableColumns(scenario.newCollection.Name)
|
||||
if len(cols) != len(scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Expected columns %v, got %v", i, scenario.expectedColumns, cols)
|
||||
cols, _ := app.Dao().TableColumns(s.newCollection.Name)
|
||||
if len(cols) != len(s.expectedColumns) {
|
||||
t.Errorf("[%s] Expected columns %v, got %v", s.name, s.expectedColumns, cols)
|
||||
}
|
||||
|
||||
for _, c := range cols {
|
||||
if !list.ExistInSlice(c, scenario.expectedColumns) {
|
||||
t.Errorf("(%d) Couldn't find column %s in %v", i, c, scenario.expectedColumns)
|
||||
if !list.ExistInSlice(c, s.expectedColumns) {
|
||||
t.Errorf("[%s] Couldn't find column %s in %v", s.name, c, s.expectedColumns)
|
||||
}
|
||||
}
|
||||
|
||||
indexes, _ := app.Dao().TableIndexes(s.newCollection.Name)
|
||||
|
||||
if totalIndexes := len(indexes); totalIndexes != s.expectedIndexesCount {
|
||||
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.name, s.expectedIndexesCount, totalIndexes, indexes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleVsMultipleValuesNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// mock field changes
|
||||
{
|
||||
selectOneField := collection.Schema.GetFieldByName("select_one")
|
||||
opt := selectOneField.Options.(*schema.SelectOptions)
|
||||
opt.MaxSelect = 2
|
||||
}
|
||||
{
|
||||
selectManyField := collection.Schema.GetFieldByName("select_many")
|
||||
opt := selectManyField.Options.(*schema.SelectOptions)
|
||||
opt.MaxSelect = 1
|
||||
}
|
||||
{
|
||||
fileOneField := collection.Schema.GetFieldByName("file_one")
|
||||
opt := fileOneField.Options.(*schema.FileOptions)
|
||||
opt.MaxSelect = 2
|
||||
}
|
||||
{
|
||||
fileManyField := collection.Schema.GetFieldByName("file_many")
|
||||
opt := fileManyField.Options.(*schema.FileOptions)
|
||||
opt.MaxSelect = 1
|
||||
}
|
||||
{
|
||||
relOneField := collection.Schema.GetFieldByName("rel_one")
|
||||
opt := relOneField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(2)
|
||||
}
|
||||
{
|
||||
relManyField := collection.Schema.GetFieldByName("rel_many")
|
||||
opt := relManyField.Options.(*schema.RelationOptions)
|
||||
opt.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
{
|
||||
// new multivaluer field to check whether the array normalization
|
||||
// will be applied for already inserted data
|
||||
collection.Schema.AddField(&schema.SchemaField{
|
||||
Name: "new_multiple",
|
||||
Type: schema.FieldTypeSelect,
|
||||
Options: &schema.SelectOptions{
|
||||
Values: []string{"a", "b", "c"},
|
||||
MaxSelect: 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveCollection(collection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ensures that the writable schema was reverted to its expected default
|
||||
var writableSchema bool
|
||||
app.Dao().DB().NewQuery("PRAGMA writable_schema").Row(&writableSchema)
|
||||
if writableSchema == true {
|
||||
t.Fatalf("Expected writable_schema to be OFF, got %v", writableSchema)
|
||||
}
|
||||
|
||||
// check whether the columns DEFAULT definition was updated
|
||||
// ---------------------------------------------------------------
|
||||
tableInfo, err := app.Dao().TableInfo(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tableInfoExpectations := map[string]string{
|
||||
"select_one": `'[]'`,
|
||||
"select_many": `''`,
|
||||
"file_one": `'[]'`,
|
||||
"file_many": `''`,
|
||||
"rel_one": `'[]'`,
|
||||
"rel_many": `''`,
|
||||
"new_multiple": `'[]'`,
|
||||
}
|
||||
for col, dflt := range tableInfoExpectations {
|
||||
t.Run("check default for "+col, func(t *testing.T) {
|
||||
var row *models.TableInfoRow
|
||||
for _, r := range tableInfo {
|
||||
if r.Name == col {
|
||||
row = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
t.Fatalf("Missing info for column %q", col)
|
||||
}
|
||||
|
||||
if v := row.DefaultValue.String(); v != dflt {
|
||||
t.Fatalf("Expected default value %q, got %q", dflt, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// check whether the values were normalized
|
||||
// ---------------------------------------------------------------
|
||||
type fieldsExpectation struct {
|
||||
SelectOne string `db:"select_one"`
|
||||
SelectMany string `db:"select_many"`
|
||||
FileOne string `db:"file_one"`
|
||||
FileMany string `db:"file_many"`
|
||||
RelOne string `db:"rel_one"`
|
||||
RelMany string `db:"rel_many"`
|
||||
NewMultiple string `db:"new_multiple"`
|
||||
}
|
||||
|
||||
fieldsScenarios := []struct {
|
||||
recordId string
|
||||
expected fieldsExpectation
|
||||
}{
|
||||
{
|
||||
"imy661ixudk5izi",
|
||||
fieldsExpectation{
|
||||
SelectOne: `[]`,
|
||||
SelectMany: ``,
|
||||
FileOne: `[]`,
|
||||
FileMany: ``,
|
||||
RelOne: `[]`,
|
||||
RelMany: ``,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"al1h9ijdeojtsjy",
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionB`,
|
||||
FileOne: `["300_Jsjq7RdBgA.png"]`,
|
||||
FileMany: ``,
|
||||
RelOne: `["84nmscqy84lsi1t"]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
"84nmscqy84lsi1t",
|
||||
fieldsExpectation{
|
||||
SelectOne: `["optionB"]`,
|
||||
SelectMany: `optionC`,
|
||||
FileOne: `["test_d61b33QdDU.txt"]`,
|
||||
FileMany: `test_tC1Yc87DfC.txt`,
|
||||
RelOne: `[]`,
|
||||
RelMany: `oap640cot4yru2s`,
|
||||
NewMultiple: `[]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range fieldsScenarios {
|
||||
t.Run("check fields for record "+s.recordId, func(t *testing.T) {
|
||||
result := new(fieldsExpectation)
|
||||
|
||||
err := app.Dao().DB().Select(
|
||||
"select_one",
|
||||
"select_many",
|
||||
"file_one",
|
||||
"file_many",
|
||||
"rel_one",
|
||||
"rel_many",
|
||||
"new_multiple",
|
||||
).From(collection.Name).Where(dbx.HashExp{"id": s.recordId}).One(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load record: %v", err)
|
||||
}
|
||||
|
||||
encodedResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode result: %v", err)
|
||||
}
|
||||
|
||||
encodedExpectation, err := json.Marshal(s.expected)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encode expectation: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.EqualFold(encodedExpectation, encodedResult) {
|
||||
t.Fatalf("Expected \n%s, \ngot \n%s", encodedExpectation, encodedResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+471
-12
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,7 +18,9 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestRecordQuery(t *testing.T) {
|
||||
func TestRecordQueryWithDifferentCollectionValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -28,15 +29,39 @@ func TestRecordQuery(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
scenarios := []struct {
|
||||
name any
|
||||
collection any
|
||||
expectedTotal int
|
||||
expectError bool
|
||||
}{
|
||||
{"with nil value", nil, 0, true},
|
||||
{"with invalid or missing collection id/name", "missing", 0, true},
|
||||
{"with pointer model", collection, 3, false},
|
||||
{"with value model", *collection, 3, false},
|
||||
{"with name", "demo1", 3, false},
|
||||
{"with id", "wsmn24bux7wo113", 3, false},
|
||||
}
|
||||
|
||||
sql := app.Dao().RecordQuery(collection).Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
for _, s := range scenarios {
|
||||
var records []*models.Record
|
||||
err := app.Dao().RecordQuery(s.collection).All(&records)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasError %v, got %v", s.name, s.expectError, hasErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if total := len(records); total != s.expectedTotal {
|
||||
t.Errorf("[%s] Expected %d records, got %d", s.name, s.expectedTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordQueryOneWithRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -61,6 +86,8 @@ func TestRecordQueryOneWithRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -122,6 +149,8 @@ func TestRecordQueryAllWithRecordsSlices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordById(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -182,6 +211,8 @@ func TestFindRecordById(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordsByIds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -273,6 +304,8 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindRecordsByExpr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -343,6 +376,8 @@ func TestFindRecordsByExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -405,7 +440,415 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRecordsByFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
sort string
|
||||
limit int
|
||||
offset int
|
||||
params []dbx.Params
|
||||
expectError bool
|
||||
expectRecordIds []string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"simple filter",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
},
|
||||
{
|
||||
"multi-condition filter with sort",
|
||||
"demo2",
|
||||
"id != '' && active=true",
|
||||
"-created,title",
|
||||
-1, // should behave the same as 0
|
||||
0,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"0yxhwia2amd8gec",
|
||||
"achvryl401bhse3",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with limit and offset",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
"title",
|
||||
2,
|
||||
1,
|
||||
nil,
|
||||
false,
|
||||
[]string{
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with placeholder params",
|
||||
"demo2",
|
||||
"active = {:active}",
|
||||
"",
|
||||
10,
|
||||
0,
|
||||
[]dbx.Params{{"active": false}},
|
||||
false,
|
||||
[]string{
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
},
|
||||
{
|
||||
"with json filter and sort",
|
||||
"demo4",
|
||||
"json_object != null && json_object.a.b = 'test'",
|
||||
"-json_object.a",
|
||||
10,
|
||||
0,
|
||||
[]dbx.Params{{"active": false}},
|
||||
false,
|
||||
[]string{
|
||||
"i9naidtvr6qsgb4",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
records, err := app.Dao().FindRecordsByFilter(
|
||||
s.collectionIdOrName,
|
||||
s.filter,
|
||||
s.sort,
|
||||
s.limit,
|
||||
s.offset,
|
||||
s.params...,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
if len(records) != len(s.expectRecordIds) {
|
||||
t.Fatalf("[%s] Expected %d records, got %d", s.name, len(s.expectRecordIds), len(records))
|
||||
}
|
||||
|
||||
for i, id := range s.expectRecordIds {
|
||||
if id != records[i].Id {
|
||||
t.Fatalf("[%s] Expected record with id %q, got %q at index %d", s.name, id, records[i].Id, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirstRecordByFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
collectionIdOrName string
|
||||
filter string
|
||||
params []dbx.Params
|
||||
expectError bool
|
||||
expectRecordId string
|
||||
}{
|
||||
{
|
||||
"missing collection",
|
||||
"missing",
|
||||
"id != ''",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"missing filter",
|
||||
"demo2",
|
||||
"",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"invalid filter",
|
||||
"demo2",
|
||||
"someMissingField > 1",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter but no matches",
|
||||
"demo2",
|
||||
"id = 'test'",
|
||||
nil,
|
||||
true,
|
||||
"",
|
||||
},
|
||||
{
|
||||
"valid filter and multiple matches",
|
||||
"demo2",
|
||||
"id != ''",
|
||||
nil,
|
||||
false,
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
{
|
||||
"with placeholder params",
|
||||
"demo2",
|
||||
"active = {:active}",
|
||||
[]dbx.Params{{"active": false}},
|
||||
false,
|
||||
"llvuca81nly1qls",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByFilter(s.collectionIdOrName, s.filter, s.params...)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Id != s.expectRecordId {
|
||||
t.Errorf("[%s] Expected record with id %q, got %q", s.name, s.expectRecordId, record.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAccessRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
admin, err := app.Dao().FindAdminByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authRecord, err := app.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record, err := app.Dao().FindRecordById("demo1", "imy661ixudk5izi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
record *models.Record
|
||||
requestInfo *models.RequestInfo
|
||||
rule *string
|
||||
expected bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"as admin with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with non-empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id = ''"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as admin with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Admin: admin,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"), // the filter rule should be ignored
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as guest with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{},
|
||||
types.Pointer("@request.auth.id != ''"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as guest with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' || @request.data.test = 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with nil rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with empty rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer(""),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with invalid rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
},
|
||||
types.Pointer("id ?!@ 1"),
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"as auth record with mismatched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 1},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"as auth record with matched rule",
|
||||
record,
|
||||
&models.RequestInfo{
|
||||
AuthRecord: authRecord,
|
||||
Data: map[string]any{"test": 2},
|
||||
},
|
||||
types.Pointer("@request.auth.id != '' && @request.data.test > 1"),
|
||||
true,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().CanAccessRecord(s.record, s.requestInfo, s.rule)
|
||||
|
||||
if result != s.expected {
|
||||
t.Errorf("[%s] Expected %v, got %v", s.name, s.expected, result)
|
||||
}
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRecordValueUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -455,6 +898,8 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -517,6 +962,8 @@ func TestFindAuthRecordByToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -548,6 +995,8 @@ func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -580,6 +1029,8 @@ func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -617,6 +1068,8 @@ func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -650,6 +1103,8 @@ func TestSaveRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -682,6 +1137,8 @@ func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -748,17 +1205,19 @@ func TestDeleteRecord(t *testing.T) {
|
||||
}
|
||||
// ensure that the json rel fields were prefixed
|
||||
joinedQueries := strings.Join(calledQueries, " ")
|
||||
expectedRelManyJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyJoin, calledQueries)
|
||||
expectedRelManyPart := "SELECT `demo1`.* FROM `demo1` WHERE EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid([[demo1.rel_many]]) THEN [[demo1.rel_many]] ELSE json_array([[demo1.rel_many]]) END) {{__je__}} WHERE [[__je__.value]]='"
|
||||
if !strings.Contains(joinedQueries, expectedRelManyPart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelManyPart, calledQueries)
|
||||
}
|
||||
expectedRelOneJoin := "`demo1` INNER JOIN json_each(CASE WHEN json_valid([[demo1.rel_one]]) THEN [[demo1.rel_one]] ELSE json_array([[demo1.rel_one]]) END)"
|
||||
if !strings.Contains(joinedQueries, expectedRelOneJoin) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOneJoin, calledQueries)
|
||||
expectedRelOnePart := "SELECT `demo1`.* FROM `demo1` WHERE (`demo1`.`rel_one`='"
|
||||
if !strings.Contains(joinedQueries, expectedRelOnePart) {
|
||||
t.Fatalf("(rec3) Expected the cascade delete to call the query \n%v, got \n%v", expectedRelOnePart, calledQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRecordBatchProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RequestQuery returns a new Request logs select query.
|
||||
func (dao *Dao) RequestQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Request{})
|
||||
}
|
||||
|
||||
// FindRequestById finds a single Request log by its id.
|
||||
func (dao *Dao) FindRequestById(id string) (*models.Request, error) {
|
||||
model := &models.Request{}
|
||||
|
||||
err := dao.RequestQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
type RequestsStatsItem struct {
|
||||
Total int `db:"total" json:"total"`
|
||||
Date types.DateTime `db:"date" json:"date"`
|
||||
}
|
||||
|
||||
// RequestsStats returns hourly grouped requests logs statistics.
|
||||
func (dao *Dao) RequestsStats(expr dbx.Expression) ([]*RequestsStatsItem, error) {
|
||||
result := []*RequestsStatsItem{}
|
||||
|
||||
query := dao.RequestQuery().
|
||||
Select("count(id) as total", "strftime('%Y-%m-%d %H:00:00', created) as date").
|
||||
GroupBy("date")
|
||||
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
|
||||
err := query.All(&result)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// DeleteOldRequests delete all requests that are created before createdBefore.
|
||||
func (dao *Dao) DeleteOldRequests(createdBefore time.Time) error {
|
||||
m := models.Request{}
|
||||
tableName := m.TableName()
|
||||
|
||||
formattedDate := createdBefore.UTC().Format(types.DefaultDateLayout)
|
||||
expr := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": formattedDate})
|
||||
|
||||
_, err := dao.DB().Delete(tableName, expr).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveRequest upserts the provided Request model.
|
||||
func (dao *Dao) SaveRequest(request *models.Request) error {
|
||||
return dao.Save(request)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestSaveAndFindSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+34
-4
@@ -21,8 +21,8 @@ func (dao *Dao) HasTable(tableName string) bool {
|
||||
return err == nil && exists
|
||||
}
|
||||
|
||||
// GetTableColumns returns all column names of a single table by its name.
|
||||
func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
|
||||
// TableColumns returns all column names of a single table by its name.
|
||||
func (dao *Dao) TableColumns(tableName string) ([]string, error) {
|
||||
columns := []string{}
|
||||
|
||||
err := dao.DB().NewQuery("SELECT name FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
@@ -32,8 +32,8 @@ func (dao *Dao) GetTableColumns(tableName string) ([]string, error) {
|
||||
return columns, err
|
||||
}
|
||||
|
||||
// GetTableInfo returns the `table_info` pragma result for the specified table.
|
||||
func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
// TableInfo returns the `table_info` pragma result for the specified table.
|
||||
func (dao *Dao) TableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
info := []*models.TableInfoRow{}
|
||||
|
||||
err := dao.DB().NewQuery("SELECT * FROM PRAGMA_TABLE_INFO({:tableName})").
|
||||
@@ -52,6 +52,36 @@ func (dao *Dao) GetTableInfo(tableName string) ([]*models.TableInfoRow, error) {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// TableIndexes returns a name grouped map with all non empty index of the specified table.
|
||||
//
|
||||
// Note: This method doesn't return an error on nonexisting table.
|
||||
func (dao *Dao) TableIndexes(tableName string) (map[string]string, error) {
|
||||
indexes := []struct {
|
||||
Name string
|
||||
Sql string
|
||||
}{}
|
||||
|
||||
err := dao.DB().Select("name", "sql").
|
||||
From("sqlite_master").
|
||||
AndWhere(dbx.NewExp("sql is not null")).
|
||||
AndWhere(dbx.HashExp{
|
||||
"type": "index",
|
||||
"tbl_name": tableName,
|
||||
}).
|
||||
All(&indexes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(indexes))
|
||||
|
||||
for _, idx := range indexes {
|
||||
result[idx.Name] = idx.Sql
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteTable drops the specified table.
|
||||
//
|
||||
// This method is a no-op if a table with the provided name doesn't exist.
|
||||
|
||||
+58
-4
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestHasTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -35,7 +37,9 @@ func TestHasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTableColumns(t *testing.T) {
|
||||
func TestTableColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -48,7 +52,7 @@ func TestGetTableColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
columns, _ := app.Dao().GetTableColumns(s.tableName)
|
||||
columns, _ := app.Dao().TableColumns(s.tableName)
|
||||
|
||||
if len(columns) != len(s.expected) {
|
||||
t.Errorf("[%d] Expected columns %v, got %v", i, s.expected, columns)
|
||||
@@ -63,7 +67,9 @@ func TestGetTableColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTableInfo(t *testing.T) {
|
||||
func TestTableInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -80,7 +86,7 @@ func TestGetTableInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
rows, _ := app.Dao().GetTableInfo(s.tableName)
|
||||
rows, _ := app.Dao().TableInfo(s.tableName)
|
||||
|
||||
raw, _ := json.Marshal(rows)
|
||||
rawStr := string(raw)
|
||||
@@ -92,6 +98,8 @@ func TestGetTableInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -116,6 +124,8 @@ func TestDeleteTable(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVacuum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -139,3 +149,47 @@ func TestVacuum(t *testing.T) {
|
||||
t.Fatalf("Expected VACUUM query, got %s", calledQueries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
table string
|
||||
expectError bool
|
||||
expectIndexes []string
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
false,
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
false,
|
||||
[]string{"idx_demo2_created", "idx_unique_demo2_title", "idx_demo2_active"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
result, err := app.Dao().TableIndexes(s.table)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v", s.table, s.expectError, hasErr)
|
||||
}
|
||||
|
||||
if len(s.expectIndexes) != len(result) {
|
||||
t.Errorf("[%s] Expected %d indexes, got %d:\n%v", s.table, len(s.expectIndexes), len(result), result)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range s.expectIndexes {
|
||||
if result[name] == "" {
|
||||
t.Errorf("[%s] Missing index %q in \n%v", s.table, name, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-32
@@ -43,10 +43,10 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
trimmed := strings.Trim(selectQuery, ";")
|
||||
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
|
||||
// try to eagerly detect multiple inline statements
|
||||
tk := tokenizer.NewFromString(trimmed)
|
||||
tk := tokenizer.NewFromString(selectQuery)
|
||||
tk.Separators(';')
|
||||
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
|
||||
return errors.New("multiple statements are not supported")
|
||||
@@ -56,14 +56,18 @@ func (dao *Dao) SaveView(name string, selectQuery string) error {
|
||||
//
|
||||
// note: the query is wrapped in a secondary SELECT as a rudimentary
|
||||
// measure to discourage multiple inline sql statements execution.
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, trimmed)
|
||||
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
|
||||
if _, err := txDao.DB().NewQuery(viewQuery).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch the view table info to ensure that the view was created
|
||||
// because missing tables or columns won't return an error
|
||||
if _, err := txDao.GetTableInfo(name); err != nil {
|
||||
if _, err := txDao.TableInfo(name); err != nil {
|
||||
// manually cleanup previously created view in case the func
|
||||
// is called in a nested transaction and the error is discarded
|
||||
txDao.DeleteView(name)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
|
||||
defer txDao.DeleteView(tempView)
|
||||
|
||||
// extract the generated view table info
|
||||
info, err := txDao.GetTableInfo(tempView)
|
||||
info, err := txDao.TableInfo(tempView)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,7 +127,7 @@ func (dao *Dao) CreateViewSchema(selectQuery string) (schema.Schema, error) {
|
||||
}
|
||||
|
||||
if !hasId {
|
||||
return errors.New("missing required id column (you ca use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
|
||||
return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -189,15 +193,18 @@ func (dao *Dao) FindRecordByViewFile(
|
||||
|
||||
record := &models.Record{}
|
||||
|
||||
err = dao.RecordQuery(qf.collection).
|
||||
InnerJoin(fmt.Sprintf(
|
||||
// note: the case is used to normalize the value access
|
||||
query := dao.RecordQuery(qf.collection).Limit(1)
|
||||
|
||||
if opt, ok := qf.original.Options.(schema.MultiValuer); !ok || !opt.IsMultiple() {
|
||||
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
|
||||
} else {
|
||||
query.InnerJoin(fmt.Sprintf(
|
||||
`json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{_je_file}}`,
|
||||
cleanFieldName, cleanFieldName, cleanFieldName,
|
||||
), dbx.HashExp{"_je_file.value": filename}).
|
||||
Limit(1).
|
||||
One(record)
|
||||
if err != nil {
|
||||
), dbx.HashExp{"_je_file.value": filename})
|
||||
}
|
||||
|
||||
if err := query.One(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -225,9 +232,14 @@ func defaultViewField(name string) *schema.SchemaField {
|
||||
return &schema.SchemaField{
|
||||
Name: name,
|
||||
Type: schema.FieldTypeJson,
|
||||
Options: &schema.JsonOptions{
|
||||
MaxSize: 1, // the size doesn't matter in this case
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var castRegex = regexp.MustCompile(`(?i)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
|
||||
|
||||
func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField, error) {
|
||||
p := new(identifiersParser)
|
||||
if err := p.parse(selectQuery); err != nil {
|
||||
@@ -250,15 +262,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
for _, col := range p.columns {
|
||||
colLower := strings.ToLower(col.original)
|
||||
|
||||
// numeric expression cast
|
||||
if strings.Contains(colLower, "(") &&
|
||||
(strings.HasPrefix(colLower, "count(") ||
|
||||
strings.HasPrefix(colLower, "total(") ||
|
||||
strings.Contains(colLower, " as numeric") ||
|
||||
strings.Contains(colLower, " as real") ||
|
||||
strings.Contains(colLower, " as int") ||
|
||||
strings.Contains(colLower, " as integer") ||
|
||||
strings.Contains(colLower, " as decimal")) {
|
||||
// numeric aggregations
|
||||
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
@@ -268,20 +273,49 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
continue
|
||||
}
|
||||
|
||||
castMatch := castRegex.FindStringSubmatch(colLower)
|
||||
|
||||
// numeric casts
|
||||
if len(castMatch) == 2 {
|
||||
switch castMatch[1] {
|
||||
case "real", "integer", "int", "decimal", "numeric":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeNumber,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "text":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
}
|
||||
continue
|
||||
case "boolean", "bool":
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
Type: schema.FieldTypeBool,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(col.original, ".")
|
||||
|
||||
var fieldName string
|
||||
var collection *models.Collection
|
||||
var isMainTableField bool
|
||||
|
||||
if len(parts) == 2 {
|
||||
fieldName = parts[1]
|
||||
collection = collections[parts[0]]
|
||||
isMainTableField = parts[0] == mainTable.alias
|
||||
} else {
|
||||
fieldName = parts[0]
|
||||
collection = collections[mainTable.alias]
|
||||
isMainTableField = true
|
||||
}
|
||||
|
||||
// fallback to the default field if the found column is not from a collection schema
|
||||
@@ -307,6 +341,7 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
|
||||
if field != nil {
|
||||
clone := *field
|
||||
clone.Id = "" // unset to prevent duplications if the same field is aliased multiple times
|
||||
clone.Name = col.alias
|
||||
result[col.alias] = &queryField{
|
||||
field: &clone,
|
||||
@@ -316,8 +351,8 @@ func (dao *Dao) parseQueryToFields(selectQuery string) (map[string]*queryField,
|
||||
continue
|
||||
}
|
||||
|
||||
if fieldName == schema.FieldNameId && !isMainTableField {
|
||||
// convert to relation since it is a direct id reference to non-maintable collection
|
||||
if fieldName == schema.FieldNameId {
|
||||
// convert to relation since it is a direct id reference
|
||||
result[col.alias] = &queryField{
|
||||
field: &schema.SchemaField{
|
||||
Name: col.alias,
|
||||
@@ -426,7 +461,7 @@ type identifiersParser struct {
|
||||
}
|
||||
|
||||
func (p *identifiersParser) parse(selectQuery string) error {
|
||||
str := strings.Trim(selectQuery, ";")
|
||||
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
|
||||
str = joinReplaceRegex.ReplaceAllString(str, " _join_ ")
|
||||
str = discardReplaceRegex.ReplaceAllString(str, " _discard_ ")
|
||||
str = commentsReplaceRegex.ReplaceAllString(str, "")
|
||||
@@ -458,6 +493,8 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
skip = false
|
||||
partType = "select"
|
||||
activeBuilder = &selectParts
|
||||
case "distinct":
|
||||
continue // ignore as it is not important for the identifiers parsing
|
||||
case "from":
|
||||
skip = false
|
||||
partType = "from"
|
||||
@@ -473,7 +510,7 @@ func (p *identifiersParser) parse(selectQuery string) error {
|
||||
partType = "join"
|
||||
activeBuilder = &joinParts
|
||||
case "_discard_":
|
||||
// do nothing...
|
||||
// skip following tokens
|
||||
skip = true
|
||||
default:
|
||||
isJoin := partType == "join"
|
||||
@@ -565,13 +602,20 @@ func identifierFromParts(parts []string) (identifier, error) {
|
||||
}
|
||||
|
||||
result.original = trimRawIdentifier(result.original)
|
||||
result.alias = trimRawIdentifier(result.alias)
|
||||
|
||||
// we trim the single quote even though it is not a valid column quote character
|
||||
// because SQLite allows it if the context expects an identifier and not string literal
|
||||
// (https://www.sqlite.org/lang_keywords.html)
|
||||
result.alias = trimRawIdentifier(result.alias, "'")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func trimRawIdentifier(rawIdentifier string) string {
|
||||
const trimChars = "`\"[];"
|
||||
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
|
||||
trimChars := "`\"[];"
|
||||
if len(extraTrimChars) > 0 {
|
||||
trimChars += strings.Join(extraTrimChars, "")
|
||||
}
|
||||
|
||||
parts := strings.Split(rawIdentifier, ".")
|
||||
|
||||
|
||||
+136
-46
@@ -5,13 +5,36 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func ensureNoTempViews(app core.App, t *testing.T) {
|
||||
var total int
|
||||
|
||||
err := app.Dao().DB().Select("count(*)").
|
||||
From("sqlite_schema").
|
||||
AndWhere(dbx.HashExp{"type": "view"}).
|
||||
AndWhere(dbx.NewExp(`[[name]] LIKE '%\_temp\_%' ESCAPE '\'`)).
|
||||
Limit(1).
|
||||
Row(&total)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check for temp views: %v", err)
|
||||
}
|
||||
|
||||
if total > 0 {
|
||||
t.Fatalf("Expected all temp views to be deleted, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -34,9 +57,13 @@ func TestDeleteView(t *testing.T) {
|
||||
t.Errorf("[%d - %q] Expected hasErr %v, got %v (%v)", i, s.viewName, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestSaveView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -78,7 +105,7 @@ func TestSaveView(t *testing.T) {
|
||||
{
|
||||
"missing table",
|
||||
"123Test",
|
||||
"select * from missing",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
@@ -124,38 +151,59 @@ func TestSaveView(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
t.Run(s.scenarioName, func(t *testing.T) {
|
||||
err := app.Dao().SaveView(s.viewName, s.query)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.scenarioName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().GetTableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch table info for %s: %v", s.scenarioName, s.viewName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Errorf("[%s] Expected %d columns, got %d", s.scenarioName, len(s.expectColumns), len(infoRows))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Errorf("[%s] Missing %q column in %v", s.scenarioName, row.Name, s.expectColumns)
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
return
|
||||
}
|
||||
|
||||
infoRows, err := app.Dao().TableInfo(s.viewName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch table info for %s: %v", s.viewName, err)
|
||||
}
|
||||
|
||||
if len(s.expectColumns) != len(infoRows) {
|
||||
t.Fatalf("Expected %d columns, got %d", len(s.expectColumns), len(infoRows))
|
||||
}
|
||||
|
||||
for _, row := range infoRows {
|
||||
if !list.ExistInSlice(row.Name, s.expectColumns) {
|
||||
t.Fatalf("Missing %q column in %v", row.Name, s.expectColumns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewSchemaWithDiscardedNestedTransaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
_, err := txDao.CreateViewSchema("select id from missing")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestCreateViewSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -179,7 +227,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"missing table",
|
||||
"select * from missing",
|
||||
"select id from missing",
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
@@ -231,24 +279,26 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"datetime",
|
||||
"json",
|
||||
"rel_one",
|
||||
"rel_many"
|
||||
"rel_many",
|
||||
'single_quoted_custom_literal' as 'single_quoted_column'
|
||||
from demo1
|
||||
`,
|
||||
false,
|
||||
map[string]string{
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"text": schema.FieldTypeText,
|
||||
"bool": schema.FieldTypeBool,
|
||||
"url": schema.FieldTypeUrl,
|
||||
"select_one": schema.FieldTypeSelect,
|
||||
"select_many": schema.FieldTypeSelect,
|
||||
"file_one": schema.FieldTypeFile,
|
||||
"file_many": schema.FieldTypeFile,
|
||||
"number_alias": schema.FieldTypeNumber,
|
||||
"email": schema.FieldTypeEmail,
|
||||
"datetime": schema.FieldTypeDate,
|
||||
"json": schema.FieldTypeJson,
|
||||
"rel_one": schema.FieldTypeRelation,
|
||||
"rel_many": schema.FieldTypeRelation,
|
||||
"single_quoted_column": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -289,7 +339,7 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with numeric casts",
|
||||
"query with casts",
|
||||
`select
|
||||
a.id,
|
||||
count(a.id) count,
|
||||
@@ -298,6 +348,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
cast(a.id as real) cast_real,
|
||||
cast(a.id as decimal) cast_decimal,
|
||||
cast(a.id as numeric) cast_numeric,
|
||||
cast(a.id as text) cast_text,
|
||||
cast(a.id as bool) cast_bool,
|
||||
cast(a.id as boolean) cast_boolean,
|
||||
avg(a.id) avg,
|
||||
sum(a.id) sum,
|
||||
total(a.id) total,
|
||||
@@ -313,6 +366,9 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"cast_real": schema.FieldTypeNumber,
|
||||
"cast_decimal": schema.FieldTypeNumber,
|
||||
"cast_numeric": schema.FieldTypeNumber,
|
||||
"cast_text": schema.FieldTypeText,
|
||||
"cast_bool": schema.FieldTypeBool,
|
||||
"cast_boolean": schema.FieldTypeBool,
|
||||
// json because they are nullable
|
||||
"sum": schema.FieldTypeJson,
|
||||
"avg": schema.FieldTypeJson,
|
||||
@@ -354,11 +410,10 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
created created_alias,
|
||||
updated updated_alias,
|
||||
123 as custom
|
||||
from demo1
|
||||
`,
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeJson,
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"text_alias": schema.FieldTypeText,
|
||||
"url_alias": schema.FieldTypeUrl,
|
||||
"bool_alias": schema.FieldTypeBool,
|
||||
@@ -368,6 +423,37 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with distinct and reordered id column",
|
||||
`select distinct
|
||||
id as id2,
|
||||
id,
|
||||
123 as custom
|
||||
from demo1`,
|
||||
false,
|
||||
map[string]string{
|
||||
"id2": schema.FieldTypeRelation,
|
||||
"custom": schema.FieldTypeJson,
|
||||
},
|
||||
},
|
||||
{
|
||||
"query with aliasing the same field multiple times",
|
||||
`select
|
||||
a.id as id,
|
||||
a.text as alias1,
|
||||
a.text as alias2,
|
||||
b.text as alias3,
|
||||
b.text as alias4
|
||||
from demo1 a
|
||||
left join demo1 as b`,
|
||||
false,
|
||||
map[string]string{
|
||||
"alias1": schema.FieldTypeText,
|
||||
"alias2": schema.FieldTypeText,
|
||||
"alias3": schema.FieldTypeText,
|
||||
"alias4": schema.FieldTypeText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
@@ -403,9 +489,13 @@ func TestCreateViewSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureNoTempViews(app, t)
|
||||
}
|
||||
|
||||
func TestFindRecordByViewFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
+37
-5
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/ghupdate"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
)
|
||||
@@ -21,6 +22,30 @@ func main() {
|
||||
// Optional plugin flags:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
var hooksDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&hooksDir,
|
||||
"hooksDir",
|
||||
"",
|
||||
"the directory with the JS app hooks",
|
||||
)
|
||||
|
||||
var hooksWatch bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(
|
||||
&hooksWatch,
|
||||
"hooksWatch",
|
||||
true,
|
||||
"auto restart the app on pb_hooks file change",
|
||||
)
|
||||
|
||||
var hooksPool int
|
||||
app.RootCmd.PersistentFlags().IntVar(
|
||||
&hooksPool,
|
||||
"hooksPool",
|
||||
25,
|
||||
"the total prewarm goja.Runtime instances for the JS app hooks execution",
|
||||
)
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(
|
||||
&migrationsDir,
|
||||
@@ -67,19 +92,25 @@ func main() {
|
||||
// Plugins and hooks:
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// load js pb_migrations
|
||||
jsvm.MustRegisterMigrations(app, &jsvm.MigrationsOptions{
|
||||
Dir: migrationsDir,
|
||||
// load jsvm (hooks and migrations)
|
||||
jsvm.MustRegister(app, jsvm.Config{
|
||||
MigrationsDir: migrationsDir,
|
||||
HooksDir: hooksDir,
|
||||
HooksWatch: hooksWatch,
|
||||
HooksPoolSize: hooksPool,
|
||||
})
|
||||
|
||||
// migrate command (with js templates)
|
||||
migratecmd.MustRegister(app, app.RootCmd, &migratecmd.Options{
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||
// GitHub selfupdate
|
||||
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
|
||||
|
||||
app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
|
||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||
return nil
|
||||
})
|
||||
@@ -101,5 +132,6 @@ func defaultPublicDir() string {
|
||||
// most likely ran with go run
|
||||
return "./pb_public"
|
||||
}
|
||||
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -51,6 +53,8 @@ func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminLoginInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -99,6 +101,8 @@ func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -66,7 +67,7 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to fetch admin with email %s: %w", form.Email, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -75,14 +76,14 @@ func (form *AdminPasswordResetRequest) Submit(interceptors ...InterceptorFunc[*m
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return runInterceptors(admin, func(m *models.Admin) error {
|
||||
if err := mails.SendAdminPasswordReset(form.app, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
m.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.dao.SaveAdmin(m)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -74,6 +76,8 @@ func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -117,7 +121,7 @@ func TestAdminPasswordResetRequestInterceptors(t *testing.T) {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorLastResetSentAt.String() == admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
if interceptorLastResetSentAt.String() != admin.LastResetSentAt.String() {
|
||||
t.Fatalf("Expected the form model to NOT be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -31,6 +33,8 @@ func TestNewAdminUpsert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -183,6 +187,8 @@ func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -231,6 +237,8 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminUpsertWithCustomId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
|
||||
|
||||
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
|
||||
//
|
||||
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
|
||||
type AppleClientSecretCreate struct {
|
||||
app core.App
|
||||
|
||||
// ClientId is the identifier of your app (aka. Service ID).
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
|
||||
// TeamId is a 10-character string associated with your developer account
|
||||
// (usually could be found next to your name in the Apple Developer site).
|
||||
TeamId string `form:"teamId" json:"teamId"`
|
||||
|
||||
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
|
||||
// private key associated with your developer account.
|
||||
KeyId string `form:"keyId" json:"keyId"`
|
||||
|
||||
// PrivateKey is the private key associated to your app.
|
||||
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
|
||||
PrivateKey string `form:"privateKey" json:"privateKey"`
|
||||
|
||||
// Duration specifies how long the generated JWT should be considered valid.
|
||||
// The specified value must be in seconds and max 15777000 (~6months).
|
||||
Duration int `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
|
||||
// config created from the provided [core.App] instances.
|
||||
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
|
||||
form := &AppleClientSecretCreate{
|
||||
app: app,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AppleClientSecretCreate) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.ClientId, validation.Required),
|
||||
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
|
||||
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates the form and returns a new Apple Client Secret JWT.
|
||||
func (form *AppleClientSecretCreate) Submit() (string, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
claims := &jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
|
||||
Subject: form.ClientId,
|
||||
Issuer: form.TeamId,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = form.KeyId
|
||||
|
||||
return token.SignedString(signKey)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
privatePem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedKey,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
formData map[string]any
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
map[string]any{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid data",
|
||||
map[string]any{
|
||||
"clientId": "",
|
||||
"teamId": "123456789",
|
||||
"keyId": "123456789",
|
||||
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
|
||||
"duration": -1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid data",
|
||||
map[string]any{
|
||||
"clientId": "123",
|
||||
"teamId": "1234567890",
|
||||
"keyId": "1234567891",
|
||||
"privateKey": string(privatePem),
|
||||
"duration": 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewAppleClientSecretCreate(app)
|
||||
|
||||
rawData, marshalErr := json.Marshal(s.formData)
|
||||
if marshalErr != nil {
|
||||
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal(rawData, form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
secret, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
t.Errorf("[%s] Expected non-empty secret", s.name)
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
|
||||
}
|
||||
|
||||
if alg := token.Header["alg"]; alg != "ES256" {
|
||||
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
|
||||
}
|
||||
|
||||
if kid := token.Header["kid"]; kid != form.KeyId {
|
||||
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var backupNameRegex = regexp.MustCompile(`^[a-z0-9_-]+\.zip$`)
|
||||
|
||||
// BackupCreate is a request form for creating a new app backup.
|
||||
type BackupCreate struct {
|
||||
app core.App
|
||||
ctx context.Context
|
||||
|
||||
Name string `form:"name" json:"name"`
|
||||
}
|
||||
|
||||
// NewBackupCreate creates new BackupCreate request form.
|
||||
func NewBackupCreate(app core.App) *BackupCreate {
|
||||
return &BackupCreate{
|
||||
app: app,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext replaces the default form context with the provided one.
|
||||
func (form *BackupCreate) SetContext(ctx context.Context) {
|
||||
form.ctx = ctx
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *BackupCreate) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Length(1, 100),
|
||||
validation.Match(backupNameRegex),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *BackupCreate) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
if exists, err := fsys.Exists(v); err != nil || exists {
|
||||
return validation.NewError("validation_backup_name_exists", "The backup file name is invalid or already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and creates the app backup.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before creating the backup.
|
||||
func (form *BackupCreate) Submit(interceptors ...InterceptorFunc[string]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.Name, func(name string) error {
|
||||
return form.app.CreateBackup(form.ctx, name)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestBackupCreateValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
backupName string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"invalid length",
|
||||
strings.Repeat("a", 97) + ".zip",
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"valid length + invalid format",
|
||||
strings.Repeat("a", 96),
|
||||
[]string{"name"},
|
||||
},
|
||||
{
|
||||
"valid length + valid format",
|
||||
strings.Repeat("a", 96) + ".zip",
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"auto generated name",
|
||||
"",
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
form := forms.NewBackupCreate(app)
|
||||
form.Name = s.backupName
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
return
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve all created backup files
|
||||
files, err := fsys.List("")
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve backup files")
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
if total := len(files); total != 0 {
|
||||
t.Fatalf("Didn't expected backup files, found %d", total)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if total := len(files); total != 1 {
|
||||
t.Fatalf("Expected 1 backup file, got %d", total)
|
||||
return
|
||||
}
|
||||
|
||||
if s.backupName == "" {
|
||||
prefix := "pb_backup_"
|
||||
if !strings.HasPrefix(files[0].Key, prefix) {
|
||||
t.Fatalf("Expected the backup file, to have prefix %q: %q", prefix, files[0].Key)
|
||||
}
|
||||
} else if s.backupName != files[0].Key {
|
||||
t.Fatalf("Expected backup file %q, got %q", s.backupName, files[0].Key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// BackupUpload is a request form for uploading a new app backup.
|
||||
type BackupUpload struct {
|
||||
app core.App
|
||||
ctx context.Context
|
||||
|
||||
File *filesystem.File `json:"file"`
|
||||
}
|
||||
|
||||
// NewBackupUpload creates new BackupUpload request form.
|
||||
func NewBackupUpload(app core.App) *BackupUpload {
|
||||
return &BackupUpload{
|
||||
app: app,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext replaces the default form upload context with the provided one.
|
||||
func (form *BackupUpload) SetContext(ctx context.Context) {
|
||||
form.ctx = ctx
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *BackupUpload) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.File,
|
||||
validation.Required,
|
||||
validation.By(validators.UploadedFileMimeType([]string{"application/zip"})),
|
||||
validation.By(form.checkUniqueName),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *BackupUpload) checkUniqueName(value any) error {
|
||||
v, _ := value.(*filesystem.File)
|
||||
if v == nil {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
if exists, err := fsys.Exists(v.OriginalName); err != nil || exists {
|
||||
return validation.NewError("validation_backup_name_exists", "Backup file with the specified name already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and upload the backup file.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before uploading the backup.
|
||||
func (form *BackupUpload) Submit(interceptors ...InterceptorFunc[*filesystem.File]) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(form.File, func(file *filesystem.File) error {
|
||||
fsys, err := form.app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fsys.SetContext(form.ctx)
|
||||
|
||||
return fsys.UploadFile(file, file.OriginalName)
|
||||
}, interceptors...)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
func TestBackupUploadValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var zb bytes.Buffer
|
||||
zw := zip.NewWriter(&zb)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f0, _ := filesystem.NewFileFromBytes([]byte("test"), "existing")
|
||||
f1, _ := filesystem.NewFileFromBytes([]byte("456"), "nozip")
|
||||
f2, _ := filesystem.NewFileFromBytes(zb.Bytes(), "existing")
|
||||
f3, _ := filesystem.NewFileFromBytes(zb.Bytes(), "zip")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
file *filesystem.File
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"missing file",
|
||||
nil,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"non-zip file",
|
||||
f1,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"zip file with non-unique name",
|
||||
f2,
|
||||
[]string{"file"},
|
||||
},
|
||||
{
|
||||
"zip file with unique name",
|
||||
f3,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
// create a dummy backup file to simulate existing backups
|
||||
if err := fsys.UploadFile(f0, f0.OriginalName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewBackupUpload(app)
|
||||
form.File = s.file
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
expectedFiles := []*filesystem.File{f0}
|
||||
if result == nil {
|
||||
expectedFiles = append(expectedFiles, s.file)
|
||||
}
|
||||
|
||||
// retrieve all uploaded backup files
|
||||
files, err := fsys.List("")
|
||||
if err != nil {
|
||||
t.Fatal("Failed to retrieve backup files")
|
||||
}
|
||||
|
||||
if len(files) != len(expectedFiles) {
|
||||
t.Fatalf("Expected %d files, got %d", len(expectedFiles), len(files))
|
||||
}
|
||||
|
||||
for _, ef := range expectedFiles {
|
||||
exists := false
|
||||
for _, f := range files {
|
||||
if f.Key == ef.OriginalName {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("Missing expected backup file %v", ef.OriginalName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+88
-39
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
@@ -26,17 +28,18 @@ type CollectionUpsert struct {
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
ListRule *string `form:"listRule" json:"listRule"`
|
||||
ViewRule *string `form:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
Indexes types.JsonArray[string] `form:"indexes" json:"indexes"`
|
||||
ListRule *string `form:"listRule" json:"listRule"`
|
||||
ViewRule *string `form:"viewRule" json:"viewRule"`
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||
@@ -57,6 +60,7 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
|
||||
form.Type = form.collection.Type
|
||||
form.Name = form.collection.Name
|
||||
form.System = form.collection.System
|
||||
form.Indexes = form.collection.Indexes
|
||||
form.ListRule = form.collection.ListRule
|
||||
form.ViewRule = form.collection.ViewRule
|
||||
form.CreateRule = form.collection.CreateRule
|
||||
@@ -128,6 +132,7 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.Match(collectionNameRegex),
|
||||
validation.By(form.ensureNoSystemNameChange),
|
||||
validation.By(form.checkUniqueName),
|
||||
validation.By(form.checkForVia),
|
||||
),
|
||||
// validates using the type's own validation rules + some collection's specifics
|
||||
validation.Field(
|
||||
@@ -155,10 +160,24 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.When(isView, validation.Nil),
|
||||
validation.By(form.checkRule),
|
||||
),
|
||||
validation.Field(&form.Indexes, validation.By(form.checkIndexes)),
|
||||
validation.Field(&form.Options, validation.By(form.checkOptions)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkForVia(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(v), "_via_") {
|
||||
return validation.NewError("validation_invalid_name", "The name of the collection cannot contain '_via_'.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
@@ -172,11 +191,6 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
|
||||
}
|
||||
|
||||
// ensure that there is no existing table name with the same name
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,14 +244,6 @@ func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
systemDisplayFields := schema.BaseModelFieldNames()
|
||||
systemDisplayFields = append(systemDisplayFields,
|
||||
schema.FieldNameUsername,
|
||||
schema.FieldNameEmail,
|
||||
schema.FieldNameEmailVisibility,
|
||||
schema.FieldNameVerified,
|
||||
)
|
||||
|
||||
for i, field := range v.Fields() {
|
||||
if field.Type != schema.FieldTypeRelation {
|
||||
continue
|
||||
@@ -269,10 +275,10 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
collection, err := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
relCollection, _ := form.dao.FindCollectionByNameOrId(options.CollectionId)
|
||||
|
||||
// validate collectionId
|
||||
if err != nil || collection.Id != options.CollectionId {
|
||||
if relCollection == nil || relCollection.Id != options.CollectionId {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
@@ -283,17 +289,16 @@ func (form *CollectionUpsert) checkRelationFields(value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
// validate displayFields (if any)
|
||||
for _, name := range options.DisplayFields {
|
||||
if collection.Schema.GetFieldByName(name) == nil && !list.ExistInSlice(name, systemDisplayFields) {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"displayFields": validation.NewError(
|
||||
"validation_field_invalid_relation_displayFields",
|
||||
fmt.Sprintf("%q does not exist in the related %q collection.", name, collection.Name),
|
||||
),
|
||||
}},
|
||||
}
|
||||
// allow only views to have relations to other views
|
||||
// (see https://github.com/pocketbase/pocketbase/issues/3000)
|
||||
if form.Type != models.CollectionTypeView && relCollection.IsView() {
|
||||
return validation.Errors{fmt.Sprint(i): validation.Errors{
|
||||
"options": validation.Errors{
|
||||
"collectionId": validation.NewError(
|
||||
"validation_field_non_view_base_relation_collection",
|
||||
"Non view collections are not allowed to have a view relation.",
|
||||
),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,7 +385,45 @@ func (form *CollectionUpsert) checkRule(value any) error {
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule. Raw error: "+err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkIndexes(value any) error {
|
||||
v, _ := value.(types.JsonArray[string])
|
||||
|
||||
if form.Type == models.CollectionTypeView && len(v) > 0 {
|
||||
return validation.NewError(
|
||||
"validation_indexes_not_supported",
|
||||
"The collection doesn't support indexes.",
|
||||
)
|
||||
}
|
||||
|
||||
for i, rawIndex := range v {
|
||||
parsed := dbutils.ParseIndex(rawIndex)
|
||||
|
||||
if !parsed.IsValid() {
|
||||
return validation.Errors{
|
||||
strconv.Itoa(i): validation.NewError(
|
||||
"validation_invalid_index_expression",
|
||||
"Invalid CREATE INDEX expression.",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// note: we don't check the index table because it is always
|
||||
// overwritten by the daos.SyncRecordTableSchema to allow
|
||||
// easier partial modifications (eg. changing only the collection name).
|
||||
// if !strings.EqualFold(parsed.TableName, form.Name) {
|
||||
// return validation.Errors{
|
||||
// strconv.Itoa(i): validation.NewError(
|
||||
// "validation_invalid_index_table",
|
||||
// fmt.Sprintf("The index table must be the same as the collection name."),
|
||||
// ),
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -473,9 +516,15 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc[*models.Col
|
||||
form.collection.Name = form.Name
|
||||
}
|
||||
|
||||
// view schema is autogenerated on save
|
||||
// view schema is autogenerated on save and cannot have indexes
|
||||
if !form.collection.IsView() {
|
||||
form.collection.Schema = form.Schema
|
||||
|
||||
// normalize indexes format
|
||||
form.collection.Indexes = make(types.JsonArray[string], len(form.Indexes))
|
||||
for i, rawIdx := range form.Indexes {
|
||||
form.collection.Indexes[i] = dbutils.ParseIndex(rawIdx).Build()
|
||||
}
|
||||
}
|
||||
|
||||
form.collection.ListRule = form.ListRule
|
||||
|
||||
+207
-130
@@ -10,11 +10,14 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/dbutils"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -87,6 +90,8 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -100,6 +105,17 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
{"empty create (auth)", "", `{"type":"auth"}`, []string{"name"}},
|
||||
{"empty create (view)", "", `{"type":"view"}`, []string{"name", "options"}},
|
||||
{"empty update", "demo2", "{}", []string{}},
|
||||
{
|
||||
"collection and field with _via_ names",
|
||||
"",
|
||||
`{
|
||||
"name": "a_via_b",
|
||||
"schema": [
|
||||
{"name":"c_via_d","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name", "schema"},
|
||||
},
|
||||
{
|
||||
"create failure",
|
||||
"",
|
||||
@@ -114,9 +130,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
"deleteRule": "missing = '123'",
|
||||
"indexes": ["create index '' on '' ()"]
|
||||
}`,
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
|
||||
},
|
||||
{
|
||||
"create failure - existing name",
|
||||
@@ -169,25 +186,6 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - missing relation display field",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name":"test",
|
||||
"type":"relation",
|
||||
"options":{
|
||||
"collectionId":"wsmn24bux7wo113",
|
||||
"displayFields":["text", "missing"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
{
|
||||
"create failure - check auth options validators",
|
||||
"",
|
||||
@@ -234,7 +232,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": "test1='123' && emailVisibility = true",
|
||||
"createRule": "test1='123' && email != ''",
|
||||
"updateRule": "test1='123' && username != ''",
|
||||
"deleteRule": "test1='123' && id != ''"
|
||||
"deleteRule": "test1='123' && id != ''",
|
||||
"indexes": ["create index idx_test_new on anything (test1)"]
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -245,9 +244,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"schema": [
|
||||
{"id":"a123456","name":"test1","type":"url"},
|
||||
{"id":"b123456","name":"test2","type":"bool"}
|
||||
]
|
||||
],
|
||||
"indexes": ["create index idx_test_new on test_new (test1)", "invalid"]
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
[]string{"schema", "indexes"},
|
||||
},
|
||||
{
|
||||
"update success - rename fields to existing field names (aka. reusing field names)",
|
||||
@@ -326,9 +326,10 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'",
|
||||
"options": {"test": 123}
|
||||
"options": {"test": 123},
|
||||
"indexes": ["create index '' from demo2 on (id)"]
|
||||
}`,
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule", "indexes"},
|
||||
},
|
||||
{
|
||||
"update success - update all fields",
|
||||
@@ -344,7 +345,11 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"createRule": "test='123' && email != ''",
|
||||
"updateRule": "test='123' && username != ''",
|
||||
"deleteRule": "test='123' && id != ''",
|
||||
"options": {"minPasswordLength": 10}
|
||||
"options": {"minPasswordLength": 10},
|
||||
"indexes": [
|
||||
"create index idx_clients_test1 on anything (id, email, test)",
|
||||
"create unique index idx_clients_test2 on clients (id, username, email)"
|
||||
]
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -371,7 +376,8 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
"deleteRule": null,
|
||||
"indexes": []
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
@@ -390,6 +396,54 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
|
||||
// view tests
|
||||
// -----------------------------------------------------------
|
||||
{
|
||||
"base->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "base",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options":{
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"auth->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{
|
||||
"name": "test",
|
||||
"type": "relation",
|
||||
"options": {
|
||||
"collectionId": "v9gwnfh02gjq1q0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
[]string{"schema"}, // not allowed
|
||||
},
|
||||
{
|
||||
"view->view relation",
|
||||
"",
|
||||
`{
|
||||
"name": "test_view_relation",
|
||||
"type": "view",
|
||||
"options": {
|
||||
"query": "select view1.id, view1.id as rel from view1"
|
||||
}
|
||||
}`,
|
||||
[]string{}, // allowed
|
||||
},
|
||||
{
|
||||
"view create failure",
|
||||
"",
|
||||
@@ -403,12 +457,14 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
],
|
||||
"options": {
|
||||
"query": "select id, email from users; drop table _admins;"
|
||||
}
|
||||
},
|
||||
"indexes": ["create index idx_test_view on upsert_view (id)"]
|
||||
}`,
|
||||
[]string{
|
||||
"listRule",
|
||||
"viewRule",
|
||||
"options",
|
||||
"indexes", // views don't have indexes
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -483,119 +539,138 @@ func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
collection, err = app.Dao().FindCollectionByNameOrId(s.existingName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
form := forms.NewCollectionUpsert(app, collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc[*models.Collection]) forms.InterceptorNextFunc[*models.Collection] {
|
||||
return func(c *models.Collection) error {
|
||||
interceptorCalls++
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Errorf("[%s] Expected Schema \n%v, \ngot \n%v", s.testName, string(rawCollectionSchema), string(rawFormSchema))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Errorf("[%s] Missing field %s \nin \n%v", s.testName, f.Name, string(rawFormSchema))
|
||||
continue
|
||||
// parse errors
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// check interceptor calls
|
||||
expectInterceptorCalls := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectInterceptorCalls = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCalls {
|
||||
t.Fatalf("Expected interceptor to be called %d, got %d", expectInterceptorCalls, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Fatalf("Expected to find collection %q, got nil", form.Name)
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Fatalf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Fatalf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Fatalf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Fatalf("Expected ListRule %v, got %v", collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Fatalf("Expected ViewRule %v, got %v", collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Fatalf("Expected CreateRule %v, got %v", collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Fatalf("Expected UpdateRule %v, got %v", collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Fatalf("Expected DeleteRule %v, got %v", collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
rawFormSchema, _ := form.Schema.MarshalJSON()
|
||||
rawCollectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
|
||||
if len(form.Schema.Fields()) != len(collection.Schema.Fields()) {
|
||||
t.Fatalf("Expected Schema \n%v, \ngot \n%v", string(rawCollectionSchema), string(rawFormSchema))
|
||||
}
|
||||
|
||||
for _, f := range form.Schema.Fields() {
|
||||
if collection.Schema.GetFieldByName(f.Name) == nil {
|
||||
t.Fatalf("Missing field %s \nin \n%v", f.Name, string(rawFormSchema))
|
||||
}
|
||||
}
|
||||
|
||||
// check indexes (if any)
|
||||
allIndexes, _ := app.Dao().TableIndexes(form.Name)
|
||||
for _, formIdx := range form.Indexes {
|
||||
parsed := dbutils.ParseIndex(formIdx)
|
||||
parsed.TableName = form.Name
|
||||
normalizedIdx := parsed.Build()
|
||||
|
||||
var exists bool
|
||||
for _, idx := range allIndexes {
|
||||
if dbutils.ParseIndex(idx).Build() == normalizedIdx {
|
||||
exists = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Missing index %s \nin \n%v", normalizedIdx, allIndexes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -646,6 +721,8 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package forms
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -78,12 +77,9 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc[[]*models.
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
"collections_import_failure",
|
||||
"Failed to import the collections configuration.",
|
||||
"Failed to import the collections configuration. Raw error:\n"+importErr.Error(),
|
||||
)}
|
||||
})
|
||||
}, interceptors...)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestCollectionsImportValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -38,7 +40,9 @@ func TestCollectionsImportValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmit(t *testing.T) {
|
||||
totalCollections := 10
|
||||
t.Parallel()
|
||||
|
||||
totalCollections := 11
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -206,7 +210,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelBeforeDelete": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -215,7 +219,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": [
|
||||
{
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"name":"demo2_rename",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@@ -347,6 +351,28 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterDelete": totalCollections - 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lazy system table name error",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"name": "_admins",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
"name":"active",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: totalCollections,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lazy view evaluation",
|
||||
jsonData: `{
|
||||
@@ -396,48 +422,51 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
form := forms.NewCollectionsImport(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Failed to load form data: %v", loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
// check collections count
|
||||
collections := []*models.Collection{}
|
||||
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(collections) != s.expectCollectionsCount {
|
||||
t.Fatalf("Expected %d collections, got %d", s.expectCollectionsCount, len(collections))
|
||||
}
|
||||
|
||||
// check events
|
||||
if len(testApp.EventCalls) > len(s.expectEvents) {
|
||||
t.Fatalf("Expected events %v, got %v", s.expectEvents, testApp.EventCalls)
|
||||
}
|
||||
for event, expectedCalls := range s.expectEvents {
|
||||
actualCalls := testApp.EventCalls[event]
|
||||
if actualCalls != expectedCalls {
|
||||
t.Fatalf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRealtimeSubscribeValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
clientId string
|
||||
expectError bool
|
||||
|
||||
@@ -128,6 +128,8 @@ func (form *RecordEmailChangeConfirm) Submit(interceptors ...InterceptorFunc[*mo
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
|
||||
// @todo consider removing if not necessary anymore
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
interceptorsErr := runInterceptors(authRecord, func(m *models.Record) error {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -145,6 +147,8 @@ func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeConfirmInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
|
||||
return validation.NewError("validation_record_email_exists", "User email already exists.")
|
||||
return validation.NewError("validation_record_email_invalid", "User email already exists or it is invalid.")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
@@ -106,6 +108,8 @@ func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordEmailChangeRequestInterceptors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
@@ -16,9 +18,10 @@ import (
|
||||
|
||||
// RecordOAuth2LoginData defines the OA
|
||||
type RecordOAuth2LoginData struct {
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
ExternalAuth *models.ExternalAuth
|
||||
Record *models.Record
|
||||
OAuth2User *auth.AuthUser
|
||||
ProviderClient auth.Provider
|
||||
}
|
||||
|
||||
// BeforeOAuth2RecordCreateFunc defines a callback function that will
|
||||
@@ -43,7 +46,7 @@ type RecordOAuth2Login struct {
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
// The optional PKCE code verifier as part of the code_challenge sent with the initial request.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
@@ -85,8 +88,7 @@ func (form *RecordOAuth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
validation.Field(&form.RedirectUrl, validation.Required),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,6 +129,11 @@ func (form *RecordOAuth2Login) Submit(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider.SetContext(ctx)
|
||||
|
||||
// load provider configuration
|
||||
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := providerConfig.SetupProvider(provider); err != nil {
|
||||
@@ -135,11 +142,14 @@ func (form *RecordOAuth2Login) Submit(
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
var opts []oauth2.AuthCodeOption
|
||||
|
||||
if provider.PKCE() {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
|
||||
}
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
token, err := provider.FetchToken(form.Code, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -153,7 +163,11 @@ func (form *RecordOAuth2Login) Submit(
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
rel, _ := form.dao.FindFirstExternalAuthByExpr(dbx.HashExp{
|
||||
"collectionId": form.collection.Id,
|
||||
"provider": form.Provider,
|
||||
"providerId": authUser.Id,
|
||||
})
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
@@ -169,9 +183,10 @@ func (form *RecordOAuth2Login) Submit(
|
||||
}
|
||||
|
||||
interceptorData := &RecordOAuth2LoginData{
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
ExternalAuth: rel,
|
||||
Record: authRecord,
|
||||
OAuth2User: authUser,
|
||||
ProviderClient: provider,
|
||||
}
|
||||
|
||||
interceptorsErr := runInterceptors(interceptorData, func(newData *RecordOAuth2LoginData) error {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@@ -23,13 +25,13 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
[]string{"provider", "code", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"missing provider",
|
||||
@@ -49,6 +51,12 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"[#3689] any redirectUrl value",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"something"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user